Bare, mountable tus 1.0 protocol edge over the v1.7 resumable-session substrate.
TusPlug is a @behaviour Plug (init/1 + call/2) — it adds no Phoenix
dependency. Mount it under your own auth pipeline via forward, in either a
Phoenix Router:
forward "/uploads/tus", Rindle.Upload.TusPlug,
profile: MyApp.VideoProfile,
secret_key_base:
Application.compile_env!(:my_app, MyAppWeb.Endpoint)[:secret_key_base]or a Plug.Router:
forward "/uploads/tus",
to: Rindle.Upload.TusPlug,
init_opts: [profile: MyApp.VideoProfile, secret_key_base: secret]Scope
Implements tus Core + Creation + Expiration + Termination + Checksum +
Creation-Defer-Length + Concatenation extensions. The advertised
Tus-Extension header matches runtime:
creation,expiration,termination,checksum,creation-defer-length,concatenation.
Backing is local and S3 tus paths — both are shipped in this module.
| Method | Status | Notes |
|---|---|---|
OPTIONS | 204 | advertises Tus-Version/Tus-Resumable/Tus-Extension/Tus-Max-Size |
POST | 201 | Creation — Upload-Length + opaque Upload-Metadata → signed Location |
HEAD | 204 | authoritative Upload-Offset + Cache-Control: no-store |
PATCH | implemented | resumable write |
DELETE | implemented | Termination |
| other | 405 | not a tus method |
Security
Every tus URL is HMAC-signed via Plug.Crypto.sign/4 against the adopter's
secret_key_base (salt "rindle:tus:url"); the signed token is the final
path segment of the URL (Location: <mount>/<token>), resolved from
conn.path_info after forward strips the mount prefix. The token is verified
on every HEAD/PATCH/DELETE; a missing, tampered, or expired signature
returns 404/401, never 200. The signed URL is persisted only into the
redacting session_uri column and never appears in logs/telemetry/inspect.
Mounting against a storage adapter that does not advertise the :tus_upload
capability raises ArgumentError at init/1 — a deploy-time failure, never a
silent downgrade.
Deployment constraint (S3 tus backing)
When the mounted profile's storage adapter is S3-backed, the sub-5-MiB tail remainder of each PATCH is buffered on node-local disk, while the authoritative cross-PATCH bookkeeping (offset, multipart upload id, committed parts) lives in the shared DB. Because the tail buffer is node-local, the S3 tus backing REQUIRES single-node or sticky-session routing: a resumed PATCH MUST be routed to the same node that holds the in-progress tail buffer (node-affinity).
A cross-node resume — where the DB shows a mid-multipart upload but the tail
file is absent on the node that received the PATCH — is detected by the S3
adapter and fails loudly with {:error, :tus_tail_missing} (surfaced as a
5xx) rather than silently re-slicing from a fresh empty tail, which would
corrupt the assembled object. Multi-node operators MUST pin tus PATCHes to a
single node (sticky sessions / node-affinity) or accept this loud failure on
misrouted resumes. This is a documented v1 constraint; shared-storage tail
persistence is deferred.
Summary
Types
@type create_upload_result() :: {:ok, %{ session: Rindle.Domain.MediaUploadSession.t(), upload_url: String.t(), expires_at: DateTime.t() }} | {:error, term()}