Rindle ships a tus 1.0 upload edge via Rindle.Upload.TusPlug. This guide
covers the adopter-owned wiring: endpoint mount, client configuration,
capability checks, and the constraints you must keep in mind when resuming
uploads against Local or S3-backed storage.
Supported tus extensions: creation, expiration, termination, checksum, creation-defer-length, concatenation.
This guide covers:
- When to use tus instead of presigned PUT or GCS-native resumable upload
- Mounting
Rindle.Upload.TusPlugin Phoenix or plain Plug - Required endpoint and parser setup
- CORS headers for browser clients
tus-js-clientand@uppy/tusclient settings- Optional same-user resume authorization
- Doctor checks and capability honesty
- Security checklist and no-silent-downgrade rules
1. When To Use Tus
Use tus when the browser must upload directly to your app over a resumable HTTP
protocol and the storage adapter advertises :tus_upload.
- For the canonical first-run path, presigned PUT is still the narrowest upload flow.
- For GCS-native resumable sessions, prefer
Rindle.initiate_resumable_session/2. - For browser clients that need one upload URL plus
HEAD/PATCHresume, useRindle.Upload.TusPlug.
Tus is capability-gated. Mounting TusPlug against an adapter that does not
advertise :tus_upload raises at init time. This no-silent-downgrade contract
means there is no silent fallback to another upload strategy.
2. Mount TusPlug
Mount the plug under your own auth pipeline. Rindle does not add a Phoenix dependency or a hidden auth layer:
# lib/my_app_web/router.ex
forward "/uploads/tus", Rindle.Upload.TusPlug,
profile: MyApp.VideoProfile,
secret_key_base:
Application.compile_env!(:my_app, MyAppWeb.Endpoint)[:secret_key_base]The same plug can be mounted in a plain Plug.Router:
forward "/uploads/tus",
to: Rindle.Upload.TusPlug,
init_opts: [profile: MyApp.VideoProfile, secret_key_base: secret]If you want mix rindle.doctor to validate that a profile you mount here has
the required storage capability, register it explicitly:
config :rindle, :tus_profiles, [MyApp.VideoProfile]Doctor does not inspect Phoenix routes. It only checks configured tus profiles against adapter capabilities.
3. Endpoint And Parser Setup
TusPlug expects the raw application/offset+octet-stream request body to
reach the plug unchanged. In Phoenix, keep Plug.Parsers configured to pass
that content type through:
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["application/offset+octet-stream", "*/*"],
json_decoder: Phoenix.json_library()The signed tus Location URL is opaque. Treat it as a bearer credential and
reuse it byte-for-byte. Do not rebuild or append path segments client-side.
4. Browser CORS
Expose the headers tus clients need to read:
config :cors_plug,
expose: [
"Upload-Offset",
"Location",
"Upload-Length",
"Tus-Resumable",
"Upload-Expires"
]If your browser clients send custom auth headers, keep those in your normal CORS allowlist as well.
5. Client Configuration
tus-js-client
import * as tus from "tus-js-client"
const upload = new tus.Upload(file, {
endpoint: "/uploads/tus",
metadata: {
filename: file.name,
filetype: file.type
},
retryDelays: [0, 1000, 3000, 5000],
parallelUploads: 2,
uploadLengthDeferred: true,
removeFingerprintOnSuccess: true
})
const previousUploads = await upload.findPreviousUploads()
if (previousUploads.length > 0) {
upload.resumeFromPreviousUpload(previousUploads[0])
}
upload.start()LiveView helper
If your upload form already lives in LiveView, Rindle supports the supported thin helper seam rather than a full uploader abstraction. Rindle.LiveView.allow_tus_upload/4 precreates the tus resource server-side and hands the signed upload_url plus session_id / asset_id back through LiveView's :external upload metadata. The host app still owns the router mount, auth, Plug.Parsers, CORS, and sticky-session or single-node resume posture.
Required helper options:
:pathpoints at the mounted tus route.:secret_key_basemust match the secret used to mountRindle.Upload.TusPlug.
Optional helper option:
:actormay be a binary or a 1-arity function that receives the socket.
def mount(_params, _session, socket) do
{:ok,
Rindle.LiveView.allow_tus_upload(socket, :video, MyApp.VideoProfile,
path: "/uploads/tus",
secret_key_base:
Application.compile_env!(:my_app, MyAppWeb.Endpoint)[:secret_key_base],
accept: ~w(.mp4),
max_entries: 1
)}
endUse a tiny client uploader keyed by uploader: "RindleTus". Start from uploadUrl: entry.meta.upload_url, then let findPreviousUploads() and resumeFromPreviousUpload(...) preserve the server-owned tus offset truth instead of rebuilding resource URLs or inventing alternate resume semantics:
import * as tus from "tus-js-client"
let Uploaders = {}
Uploaders.RindleTus = function (entries, onViewError) {
entries.forEach((entry) => {
let upload = new tus.Upload(entry.file, {
endpoint: entry.meta.endpoint,
uploadUrl: entry.meta.upload_url,
metadata: {
filename: entry.file.name,
filetype: entry.file.type
},
retryDelays: [0, 1000, 3000, 5000],
removeFingerprintOnSuccess: true,
onError: (error) => entry.error(error.message),
onProgress: (bytesUploaded, bytesTotal) => {
let pct = Math.floor((bytesUploaded / bytesTotal) * 100)
if (pct < 100) entry.progress(pct)
},
onSuccess: () => entry.progress(100)
})
onViewError(() => upload.abort())
upload.findPreviousUploads().then((previousUploads) => {
if (previousUploads.length > 0) {
upload.resumeFromPreviousUpload(previousUploads[0])
}
upload.start()
})
})
}Keep LiveView progress and server lifecycle states separate in your UI. Freeze the public state vocabulary as uploading, verifying, ready, and error, and say plainly that 100% means bytes transferred, not asset readiness:
uploading/Uploading...while the client is sending bytesverifying/Verifying...after the upload reaches100%ready/Readyonly afterconsume_uploaded_entries/3succeedserror/Errorif upload transport or server verification fails
LiveView still finishes through consume_uploaded_entries/3 and the existing
verify_completion/2 lane:
def handle_event("save", _params, socket) do
uploaded =
Rindle.LiveView.consume_uploaded_entries(socket, :video, fn _entry, meta ->
{:ok, meta.asset_id}
end)
{:noreply, assign(socket, :uploaded_asset_ids, uploaded)}
end@uppy/tus
uppy.use(Tus, {
endpoint: "/uploads/tus",
parallelUploads: 2,
uploadLengthDeferred: true
})@uppy/tus is a compatible non-canonical option for adopters who already use
Uppy. Use parallelUploads: 2 (or higher) to activate concatenation and keep
uploadLengthDeferred: true for unknown-length uploads that negotiate
creation-defer-length. The client should HEAD for Upload-Offset and let
the library resume from the server-reported offset. For modern @uppy/tus,
resume and fingerprint cleanup are automatic, so do not add
removeFingerprintOnSuccess.
6. Optional Resume Authorization
By default, possession of a valid signed tus URL is enough to resume the upload. If you need same-user enforcement, configure a resume authorizer:
config :rindle, :tus_resume_authorizer, MyApp.TusAuthdefmodule MyApp.TusAuth do
@behaviour Rindle.TusResumeAuthorizer
@impl true
def authorize(actor, :resume, %{token_actor: token_actor}) do
if actor == token_actor, do: :ok, else: :reject
end
endThe hook runs after URL signature verification and session lookup, but before
any body or storage I/O on HEAD, PATCH, or DELETE.
7. Security Checklist
- Mount
TusPlugonly behind your own auth pipeline. - Keep the signed
LocationURL secret; it is a bearer credential. - Treat the returned
Locationas opaque and reuse it byte-for-byte. - If you use
tus-js-client, keepremoveFingerprintOnSuccess: trueso completed uploads do not reuse stale fingerprint entries. - Do not mount tus for profiles whose adapters do not advertise
:tus_upload. - For S3-backed tus uploads, keep sticky-session or single-node routing in place. Mid-upload tail state is node-local and cross-node resume fails loudly.
8. No-Silent-Downgrade Contract
Rindle does not degrade from tus to presigned PUT or multipart automatically.
If the adapter lacks :tus_upload, TusPlug.init/1 raises. If mix rindle.doctor
sees a configured tus profile without that capability, it reports the mismatch
explicitly.
Related guides
- Storage Capabilities — adapter capability matrix and tus honesty
- Storage (GCS) — GCS resumable uploads (separate from tus)
- User Flows — find-your-job map for upload paths
- Getting Started — canonical presigned PUT first run