A direct upload goes from the browser straight to the store: the file never passes through your app, however large it is. Your app signs the upload (where, what type, what size, for how long), the browser sends the file, and when it is there, Storehouse reads its first bytes and checks them. A file that is not what it said it was is deleted.

Storehouse.S3 and Storehouse.Local both take direct uploads: in development, the browser uploads to your app's own plug exactly as it would to a bucket.

In a LiveView

Storehouse.Live.allow/3 in place of allow_upload/3, and Storehouse.Live.consume/3 as for any upload:

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:photos, [])
   |> Storehouse.Live.allow(:photos,
     accept: ~w(.png .jpg .jpeg .gif .webp),
     max_file_size: 50_000_000,
     max_entries: 10,
     auto_upload: true,
     progress: &handle_progress/3
   )}
end

defp handle_progress(:photos, _entry, socket) do
  {socket, results} = Storehouse.Live.consume(socket, :photos, accept: Storehouse.Check.images())

  {:noreply,
   Enum.reduce(results, socket, fn
     {_entry, {:ok, file}}, socket -> update(socket, :photos, &[file | &1])
     {entry, {:error, reason}}, socket -> put_flash(socket, :error, "#{entry.client_name}: #{inspect(reason)}")
   end)}
end

and Storehouse's uploader in assets/js/app.js:

import {Uploaders} from "storehouse"

let liveSocket = new LiveSocket("/live", Socket, {
  uploaders: Uploaders,
  params: {_csrf_token: csrfToken}
})

("storehouse" is the package in deps/storehouse, as "phoenix" and "phoenix_live_view" are.) With uploaders of your own: {...Uploaders, ...yours}.

allow/3 signs direct uploads when the store does; with a store that does not (an adapter of yours with the three callbacks), or with mode: :server, the files come to the LiveView as a plain upload, and consume/3 stores them. Your LiveView is the same either way.

Streamed through the app: mode: :stream

When the browser cannot reach the store (a bucket with no CORS, a store in a private network, LocalStack inside a container), or you would rather it did not, mode: :stream sends the file over the LiveView's socket, and on to the store as each chunk comes (Storehouse.Live.Writer):

Storehouse.Live.allow(socket, :videos,
  mode: :stream,
  types: ["video/mp4", "video/webm"],
  accept: ~w(.mp4 .webm),
  max_file_size: 2_000_000_000,
  auto_upload: true,
  progress: &handle_progress/3
)

Nothing of the file is written to the app's disk. To S3 it goes in the parts of a multipart upload (8 MB each, :part_size of Storehouse.S3), each with its checksum, at most two on their way while the next chunks arrive, from a process of their own; the upload waits when they are, so a fast browser never fills the app's memory. Its first bytes are checked as soon as they are there: a file that is not what it said stops, and nothing of it stays in the store. The whole file's checksum is computed on the way. No uploader in app.js: it is LiveView's own.

What is signed

Each upload is signed for:

  • its key, a new one, whose extension is the claimed type's (Storehouse.Check.claimed/1): a type Storehouse does not know is a download, with a .bin key;
  • its type: the store keeps the file with it, and with Content-Disposition: attachment for every type a browser should not show;
  • its size, exactly: the store refuses a body of another size. The upload's max_file_size limits it first;
  • ten minutes to start (:expires_in).

What is checked

When the upload is done, consume/3 reads the first bytes of the file in the store (Storehouse.verify_upload/2). A file whose bytes are not of the type it was signed for (an HTML page sent as a PNG), of a type :accept refuses, or over :max_pixels, is deleted from the store, and its result is {:error, :not_accepted} or {:error, :too_large}.

Until then, the file is in the store but in nothing of yours: keep only the files consume/3 gives you.

The bucket's CORS

The browser sends the file to the bucket's origin: the bucket must allow your app's origin to PUT, with the headers Storehouse signs:

[
  {
    "AllowedOrigins": ["https://my-app.example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["content-type", "content-disposition"],
    "MaxAgeSeconds": 3600
  }
]

On AWS, in the bucket's permissions; on R2, in the bucket's settings; on LocalStack, awslocal s3api put-bucket-cors. Storehouse.Local needs none: the plug is your app.

If your app sends a Content-Security-Policy, allow the store's origin in connect-src.

Without LiveView

Storehouse.presign_upload/4 signs an upload, and Storehouse.verify_upload/2 checks it, for a controller and a form of your own, or an API:

{:ok, %{key: key, method: "PUT", url: url, headers: headers}} =
  Storehouse.presign_upload(params["name"], params["type"], params["size"],
    accept: Storehouse.Check.images(),
    max_bytes: 50_000_000
  )

# The client sends the file: PUT url, with headers, and the file as the body.

{:ok, file} = Storehouse.verify_upload(key, name: params["name"], accept: Storehouse.Check.images())