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.TusPlug in Phoenix or plain Plug
  • Required endpoint and parser setup
  • CORS headers for browser clients
  • tus-js-client and @uppy/tus client 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.

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:

  • :path points at the mounted tus route.
  • :secret_key_base must match the secret used to mount Rindle.Upload.TusPlug.

Optional helper option:

  • :actor may 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
   )}
end

Use 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 bytes
  • verifying / Verifying... after the upload reaches 100%
  • ready / Ready only after consume_uploaded_entries/3 succeeds
  • error / Error if 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.TusAuth
defmodule 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
end

The 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 TusPlug only behind your own auth pipeline.
  • Keep the signed Location URL secret; it is a bearer credential.
  • Treat the returned Location as opaque and reuse it byte-for-byte.
  • If you use tus-js-client, keep removeFingerprintOnSuccess: true so 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.