Storehouse (Storehouse v0.1.0)

Copy Markdown View Source

Files for Phoenix: a file checked by its bytes (Storehouse.Check), stored under a new, safe key through a store of the app's (Storehouse.Adapter: the local disk, S3, …), and served safely (Storehouse.Local.Plug).

{:ok, file} = Storehouse.store("priv/seeds/palette.png")
file.url
#=> "/uploads/2026/10/0b6…-palette.png"

store/2 gives a Storehouse.File, which the app keeps (its key, URL, name, type, size, and an image's width and height). A LiveView upload goes the same way, with Storehouse.Live.consume/3; a file at an address of a host the app allows, with fetch/2.

The store

config :storehouse, adapter: Storehouse.Local
config :storehouse, Storehouse.Local, root: "/var/lib/my_app/uploads"

Storehouse.Local (the default) keeps the files on the local disk; an app writes its own adapter for S3 or another store (see Storehouse.Adapter). Each call takes adapter: too.

Keys

A key is a relative path with segments of ASCII letters, digits, ., - and _, separated by /. A segment is never . or ... key/2 makes a new key in the form yyyy/mm/<uuid>-<safe name><extension>. The extension comes from the checked content type (Storehouse.Check.extension/1), never from the client's file name, so a key never ends in .html or .svg. An adapter refuses a key that valid_key?/1 refuses.

Telemetry

Storehouse emits :telemetry spans, each a :start, then a :stop (or an :exception) event, with the :duration of the work:

  • [:storehouse, :store, ...] - store/2, with :name and :adapter;
  • [:storehouse, :fetch, ...] - fetch/2, with :url (without its query) and :adapter (its store/2 has a span of its own);
  • [:storehouse, :presign_upload, ...] - presign_upload/4, with :name, :content_type, :bytes and :adapter;
  • [:storehouse, :verify_upload, ...] - verify_upload/2, with :key and :adapter;
  • [:storehouse, :delete, ...] - delete/2, with :key and :adapter;
  • [:storehouse, :promote, ...] - promote/2, with :key (the temporary one) and :adapter.

The :stop event's metadata has the outcome: result: :ok with the :file (or the :key), or result: :error with the :reason (a file refused: :not_accepted, :too_large, :not_allowed; a store's error). For example, to count the files refused:

:telemetry.attach("storehouse-refused", [:storehouse, :store, :stop], &MyApp.Metrics.file/4, nil)

Summary

Functions

Returns the configured adapter (config :storehouse, adapter:), by default Storehouse.Local.

The prefix of the keys of temporary files (cache: true): "cache/", or config :storehouse, cache_prefix: "tmp/". A bucket's lifecycle rule deletes what stays there (see the Ecto guide).

Whether a file (or a key) is a temporary one, under cache_prefix/0.

The SHA-256 of the file at path, in Base64: a Storehouse.File's checksum, to compare with a copy's.

Returns a file name for display: control and format characters (bidi controls included) removed, at most 200 characters, and "file" when nothing is left.

Deletes the file with key, through the adapter (:adapter, or adapter/0).

Downloads the file at url, from a host of :hosts, then checks and stores it as store/2 does: an image pasted by its address, a file an agent names.

Whether fetch/2 takes url with hosts (https only).

Returns a new key for a file: yyyy/mm/<uuid>-<safe name><extension>, with the year and month of now (UTC), a random UUID, and the extension of the checked content type (see safe_name/2).

Signs an upload that a browser makes straight to the store, without the file going through the app: of the file name, of the claimed type claim and of bytes bytes exactly. Gives the file's new key and the request to make (method, url, headers). Storehouse.Live.allow/3 does this for a LiveView upload.

Makes a temporary file (cache: true) permanent: copies it, in the store, to its key without the temporary prefix, deletes the temporary one, and gives the file at its new key. A file that is not temporary is given as it is.

Returns a safe file name for a key: the client's name without its extension, with only ASCII letters, digits, ., - and _, then the extension of the checked content type. At most 100 characters.

Whether url is safe to show and link to: http or https, or a path of the app (/uploads/…, not //host).

A URL of a file that is valid for a while, for a file that is not public: a bucket that is private, a local store whose plug serves only signed URLs (private: true). Keep the key, not this URL: it expires. Storehouse.Redirect gives a lasting URL of your app that redirects to a fresh one.

Checks the file at path and stores it under a new key, through the adapter. The file at path is left where it is.

Returns the URL of the file with key, through the adapter (:adapter, or adapter/0).

Returns true when key is a valid storage key (see the module doc).

Checks a file a browser uploaded straight to the store (presign_upload/4), by its first bytes, and gives it as store/2 does. A file whose bytes are not of the type it was signed for (a page sent as a PNG), of a type :accept refuses, or over a limit, is deleted, and gives {:error, :not_accepted} or {:error, :too_large}.

Functions

adapter()

@spec adapter() :: module()

Returns the configured adapter (config :storehouse, adapter:), by default Storehouse.Local.

cache_prefix()

@spec cache_prefix() :: String.t()

The prefix of the keys of temporary files (cache: true): "cache/", or config :storehouse, cache_prefix: "tmp/". A bucket's lifecycle rule deletes what stays there (see the Ecto guide).

cached?(key)

Whether a file (or a key) is a temporary one, under cache_prefix/0.

checksum(path)

@spec checksum(Path.t()) :: String.t()

The SHA-256 of the file at path, in Base64: a Storehouse.File's checksum, to compare with a copy's.

clean_name(name)

@spec clean_name(term()) :: String.t()

Returns a file name for display: control and format characters (bidi controls included) removed, at most 200 characters, and "file" when nothing is left.

Examples

iex> Storehouse.clean_name("cat\u0000.png")
"cat.png"

iex> Storehouse.clean_name("a\u202Egnp.exe")
"agnp.exe"

delete(key, opts \\ [])

@spec delete(Storehouse.Adapter.key(), keyword()) :: :ok | {:error, term()}

Deletes the file with key, through the adapter (:adapter, or adapter/0).

fetch(url, opts)

@spec fetch(String.t(), keyword()) :: {:ok, Storehouse.File.t()} | {:error, term()}

Downloads the file at url, from a host of :hosts, then checks and stores it as store/2 does: an image pasted by its address, a file an agent names.

Only https addresses (and http, with http: true) of the hosts the app allows are fetched, and a redirect only to one of them: the server never fetches an address a person or an agent chose freely, which could be one of the app's own network. A host is a name ("images.example.com") or a name with a leading *. for its subdomains ("*.example.com").

Options

  • :hosts - the hosts it fetches from (required).
  • :http - whether http addresses are fetched. The default is false.
  • :timeout - in milliseconds, to connect and for the answer. The default is 10 seconds.
  • the options of store/2; :max_bytes stops a download as soon as it is larger.

fetchable?(url, hosts)

@spec fetchable?(String.t(), [String.t()]) :: boolean()

Whether fetch/2 takes url with hosts (https only).

Examples

iex> Storehouse.fetchable?("https://images.example.com/a.png", ["*.example.com"])
true

iex> Storehouse.fetchable?("https://example.com.evil.org/a.png", ["*.example.com"])
false

key(name, content_type)

Returns a new key for a file: yyyy/mm/<uuid>-<safe name><extension>, with the year and month of now (UTC), a random UUID, and the extension of the checked content type (see safe_name/2).

Examples

iex> key = Storehouse.key("My cat (1).png", "image/png")
iex> [_year, _month, file] = String.split(key, "/")
iex> String.slice(file, 37..-1//1)
"My_cat_1_.png"

iex> "evil.svg" |> Storehouse.key("application/octet-stream") |> Path.extname()
".bin"

presign_upload(name, claim, bytes, opts \\ [])

@spec presign_upload(String.t(), String.t() | nil, non_neg_integer(), keyword()) ::
  {:ok,
   %{
     key: Storehouse.Adapter.key(),
     method: String.t(),
     url: String.t(),
     headers: map()
   }}
  | {:error, term()}

Signs an upload that a browser makes straight to the store, without the file going through the app: of the file name, of the claimed type claim and of bytes bytes exactly. Gives the file's new key and the request to make (method, url, headers). Storehouse.Live.allow/3 does this for a LiveView upload.

The key's extension is the claimed type's, as the checked type it would be (Storehouse.Check.claimed/1). When the file is there, check it with verify_upload/2: until then, it is what the browser said it is.

Options

  • :accept - the types to take: a claim of another gives {:error, :not_accepted} at once.
  • :max_bytes - a larger file gives {:error, :too_large} at once.
  • :expires_in - how long the browser has to start the upload, in seconds. The default is 600.
  • :adapter and :key, as for store/2. The adapter must sign uploads (Storehouse.Adapter.presign_upload/3): Storehouse.Local and Storehouse.S3 do; another gives {:error, :not_supported}.

promote(file, opts \\ [])

@spec promote(Storehouse.File.t(), keyword()) ::
  {:ok, Storehouse.File.t()} | {:error, term()}

Makes a temporary file (cache: true) permanent: copies it, in the store, to its key without the temporary prefix, deletes the temporary one, and gives the file at its new key. A file that is not temporary is given as it is.

It can run again for the same file: when the temporary file is gone and the permanent one is there (a save that rolled back after it, and was tried again), it gives the permanent one.

Options

safe_name(name, content_type)

@spec safe_name(String.t(), String.t()) :: String.t()

Returns a safe file name for a key: the client's name without its extension, with only ASCII letters, digits, ., - and _, then the extension of the checked content type. At most 100 characters.

Every run of other characters becomes one _, and a run of dots becomes one dot. A name with nothing left is "file".

Examples

iex> Storehouse.safe_name("résumé 2026.pdf", "application/pdf")
"r_sum_2026.pdf"

iex> Storehouse.safe_name("photo.html", "image/png")
"photo.png"

iex> Storehouse.safe_name("../../etc/passwd", "application/octet-stream")
"._._etc_passwd.bin"

iex> Storehouse.safe_name("", "text/plain")
"file.txt"

safe_url?(url)

@spec safe_url?(term()) :: boolean()

Whether url is safe to show and link to: http or https, or a path of the app (/uploads/…, not //host).

Examples

iex> Storehouse.safe_url?("/uploads/a.png")
true

iex> Storehouse.safe_url?("javascript:alert(1)")
false

iex> Storehouse.safe_url?("//evil.example/a.png")
false

signed_url(key_or_file, opts \\ [])

@spec signed_url(Storehouse.Adapter.key() | Storehouse.File.t(), keyword()) ::
  {:ok, String.t()} | {:error, term()}

A URL of a file that is valid for a while, for a file that is not public: a bucket that is private, a local store whose plug serves only signed URLs (private: true). Keep the key, not this URL: it expires. Storehouse.Redirect gives a lasting URL of your app that redirects to a fresh one.

Options

store(path, opts \\ [])

@spec store(Path.t(), keyword()) :: {:ok, Storehouse.File.t()} | {:error, term()}

Checks the file at path and stores it under a new key, through the adapter. The file at path is left where it is.

The file's type is the one its bytes prove (Storehouse.Check): a file that is not of a passive type is stored as application/octet-stream, with a key that ends in .bin, unless :accept refuses it. When the adapter stores the file but gives a URL that is not safe to show (safe_url?/1), the stored file is deleted.

Options

  • :name - the file's name, for its key and for display. The default is the base name of path.
  • :content_type - the claimed type, as a browser sends it. The default is the type of the name's extension. Only the bytes make an image an image.
  • :accept - the checked types to take, a list; another gives {:error, :not_accepted}. The default takes every one.
  • :max_bytes, :max_pixels - as Storehouse.Check.describe/3's.
  • :adapter - the store. The default is adapter/0.
  • :key - a function of the name and the checked content type that gives the key. The default is &key/2.
  • :cache - true stores the file under the temporary prefix (cache_prefix/0), until promote/2 makes it permanent: an upload of a form that may never be saved.
  • :preview - a function of the stored file and path that gives the URL of a smaller image of it (a thumbnail, a PDF's first page, a video's poster), or nil. It runs while path is still there; the app renders and stores the image. A result that is not a safe URL, or an exception, gives none and logs a warning.

url(key, opts \\ [])

@spec url(Storehouse.Adapter.key(), keyword()) :: String.t()

Returns the URL of the file with key, through the adapter (:adapter, or adapter/0).

valid_key?(key)

@spec valid_key?(term()) :: boolean()

Returns true when key is a valid storage key (see the module doc).

Examples

iex> Storehouse.valid_key?("2026/10/a-cat.png")
true

iex> Storehouse.valid_key?("2026/../../etc/passwd")
false

iex> Storehouse.valid_key?("/etc/passwd")
false

verify_upload(key, opts \\ [])

@spec verify_upload(Storehouse.Adapter.key(), keyword()) ::
  {:ok, Storehouse.File.t()} | {:error, term()}

Checks a file a browser uploaded straight to the store (presign_upload/4), by its first bytes, and gives it as store/2 does. A file whose bytes are not of the type it was signed for (a page sent as a PNG), of a type :accept refuses, or over a limit, is deleted, and gives {:error, :not_accepted} or {:error, :too_large}.

Options

  • :name - the file's name, for display. The default is the key's.
  • :accept, :max_bytes, :max_pixels, :adapter, as for store/2.
  • :preview - as for store/2, with nil for the path: the file is not here.