Storehouse

Storehouse — Files for Phoenix

Storehouse keeps the files of a Phoenix app: the photos people upload, the attachments of a document, the images on a canvas, the files an app makes itself. Each file is checked by its bytes, stored under a new, safe key, in a store of your choosing, and served so that it can never run a script on your app's origin.

It is the storage of the Hattori libraries, taken out of them: Kotoba's attachments and Hakushi's images keep their files with it, and so can your app.

Status: early release. Checking, storing, the local disk and S3 (and the stores that speak its API), uploads straight to the store, uploads streamed through the app with no temporary file, files in Ecto records (temporary until saved, permanent then), signed URLs for private files, checksums, telemetry, files from an address, and LiveView uploads are here. Before 1.0, a minor version may change the API; see the roadmap for what comes next.

Installation

def deps do
  [
    {:storehouse, "~> 0.1"}
  ]
end

Storehouse needs Elixir 1.18 or later and, for Storehouse.Live, Phoenix LiveView 1.1 or later. The rest works in any Plug app.

It gives your app:

A sketch

# config/runtime.exs
config :storehouse, Storehouse.Local, root: "/var/lib/my_app/uploads"

# lib/my_app_web/endpoint.ex, before the router
plug Storehouse.Local.Plug
{:ok, file} = Storehouse.store("priv/seeds/palette.png")

file
#=> %Storehouse.File{
#     key: "2026/10/0b6…-palette.png",
#     url: "/uploads/2026/10/0b6…-palette.png",
#     name: "palette.png",
#     content_type: "image/png",
#     bytes: 48_211,
#     width: 1600,
#     height: 1000,
#     preview: nil
#   }

In a LiveView:

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:photos, [])
   |> allow_upload(:photos,
     accept: ~w(.png .jpg .jpeg .gif .webp),
     max_file_size: 10_000_000,
     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

From an address:

Storehouse.fetch("https://images.example.com/board.png",
  hosts: ["images.example.com"],
  accept: Storehouse.Check.images(),
  max_bytes: 10_000_000
)

Straight to the store, with S3 in production and the local disk (or LocalStack) in development:

socket
|> Storehouse.Live.allow(:photos, accept: ~w(.png .jpg .jpeg), max_file_size: 50_000_000, auto_upload: true, progress: &handle_progress/3)
import {Uploaders} from "storehouse"
let liveSocket = new LiveSocket("/live", Socket, {uploaders: Uploaders, params: {_csrf_token: csrfToken}})

A private file:

{:ok, url} = Storehouse.signed_url(file.key, expires_in: 300)

In a record:

schema "users" do
  field :avatar, Storehouse.Ecto.File
end

user
|> User.changeset(attrs)
|> Storehouse.Ecto.put_file(:avatar, avatar, accept: Storehouse.Check.images(), max_bytes: 5_000_000)
|> then(&Storehouse.Ecto.update(Repo, &1))

The guides: Stores (the local disk, S3, LocalStack, development), Direct uploads (and streamed ones), Ecto, Private files, Serving and LiveView.

Roadmap

Development

mix deps.get
mix test
mix credo --strict
mix dialyzer

License

MIT. See LICENSE.