Rindle.Upload.TusPlug (Rindle v0.1.7)

Copy Markdown View Source

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.

MethodStatusNotes
OPTIONS204advertises Tus-Version/Tus-Resumable/Tus-Extension/Tus-Max-Size
POST201Creation — Upload-Length + opaque Upload-Metadata → signed Location
HEAD204authoritative Upload-Offset + Cache-Control: no-store
PATCHimplementedresumable write
DELETEimplementedTermination
other405not 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

create_upload_result()

@type create_upload_result() ::
  {:ok,
   %{
     session: Rindle.Domain.MediaUploadSession.t(),
     upload_url: String.t(),
     expires_at: DateTime.t()
   }}
  | {:error, term()}