
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"}
]
endStorehouse 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:
- files checked by their bytes, not their names: PNG, JPEG, GIF and
WebP images (with their width and height, a phone's photo turned as
its EXIF says), PDF, MP4 and WebM video, ZIP and Office documents, and
plain text. Anything else (HTML, SVG, JavaScript, a page renamed
.png) isapplication/octet-stream, a download, or refused; - safe keys:
yyyy/mm/<uuid>-<safe name><extension>, whose extension comes from the checked type, so a key never ends in.html; an adapter refuses any key that could leave its root; - stores: the local disk, and S3 with the stores that speak its API (Cloudflare R2, MinIO, Tigris, LocalStack), with no other library; or one of your own, in three callbacks;
- uploads straight to the store: the browser sends a file to the bucket at a URL your app signed (its type, its size, ten minutes), so a large file never goes through your app, and Storehouse checks it there by its first bytes; in development, to your app's own plug, the same way;
- uploads streamed through your app with no file on its disk: to S3 in the parts of a multipart upload as the chunks come, checked by their first bytes on the way, two parts in flight from a process of their own, the upload held back when the bucket is slower than the browser;
- files in Ecto records: a column of the file (
Storehouse.Ecto.File), no table of files; an upload is temporary until its record is saved, made permanent then by a copy in the store, and a bucket's lifecycle rule deletes those of forms never saved; files replaced or removed are deleted once the database has the change; - private files: signed URLs that expire, from S3 or the local plug,
and
Storehouse.Redirect, a lasting URL of your app that checks the person and redirects to a fresh one; - serving that is safe:
Storehouse.Local.Plugsends the checked type,nosniff, a sandboxing CSP, downloads for every type a browser should not show, and byte ranges, for video; - LiveView uploads, stored:
Storehouse.Live.consume/3stores each finished entry once, cancels those the upload refused, and gives you a result for each; - files from an address, from the hosts you allow only, with redirects checked and the download stopped at the size limit: an image pasted by its address, a file an agent names;
- limits: the largest file, the most pixels of an image, the types you take;
- checksums: each file's SHA-256, kept with it, and checked by S3 as it takes the file;
- telemetry:
:telemetryspans for storing, fetching, signing, checking and deleting, with what was refused and why; - previews: a function of yours gives a smaller image of a file (a thumbnail, a PDF's first page), kept with it.
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)}
endFrom 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
- Work after the upload, with no job queue: versions of an image made when it lands, previews, analysis, in supervised processes of the app (Oban, when the work must outlive a deploy).
- Variants: images resized and converted on demand, and cached, each made once however many ask for it at the same time, across a cluster.
- Previews and analysis: a PDF's first page, a video's poster, its length and size, audio; metadata of the app's own, read again later.
- Live progress beyond the upload: the versions being made, a video being read, for everyone who sees the file (PubSub).
- Cleanup of unused files: deleted files and abandoned uploads, by a supervised process, with no cron.
- Resumable uploads that survive a reconnect (and the tus protocol), and multipart uploads straight from the browser, for very large files.
- Endpoints for other clients: presign and upload endpoints, for Uppy and for apps with no LiveView.
- Mirror: writing to two stores at once, for migrating between them.
- Proxy mode: streaming private bucket files through the app, for a CDN in front.
- Azure and native Google Cloud Storage: Google Cloud Storage works through its S3-compatible API today.
Development
mix deps.get
mix test
mix credo --strict
mix dialyzer
License
MIT. See LICENSE.