Storehouse (Storehouse v0.2.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;
  • [:storehouse, :head, ...], [:storehouse, :read, ...] and [:storehouse, :download, ...] - head/2, read/2 and download/3, with :key and :adapter;
  • [:storehouse, :stream, ...] - stream/2, with :key and :adapter: the stream's opening, not its reading.

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

Gives up an upload (presign_multipart/4): its parts are deleted from the store.

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

Runs the analysis steps of a file at once (Storehouse.Analyzer), each one's analysis kept in the file's :analysis: {:ok, file}. A step that fails keeps why, and leaves the others' results; {:error, reason} is for a file that could not be read for them.

Runs the analysis steps of a file out of the request, then gives the file with their results to the app's :then, to save them: Storehouse.Ecto.analyze_later/5 saves them in a record's column.

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.

A SHA-256 as checksum/1 gives it (Base64), from hex or Base64, as a browser may send it: {:ok, checksum}, {:ok, nil} for none, or {:error, :invalid_checksum}.

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

Puts an upload's parts together (presign_multipart/4), then checks the file as verify_upload/2 does (its type by its first bytes, its size, the owner's room, the same file again) and gives it: {:ok, file}, {:existing, file}, or an error, the file deleted.

Copies the file with key from one store to another, under the same key: to move to another provider, keep a second copy, or bring a production account's files into development.

Copies every file whose key starts with prefix (see list/2) from one store to another (copy/2), several at once. Files the destination has already are skipped, so it can be run again: after an error, or to catch up with what was written since.

Deletes the file with key, through the adapter (:adapter, or adapter/0), and its stored variants (Storehouse.Variants), where the store lists files.

Deletes the file with key later, and again until the store answers: a file replaced or removed, whose record is already saved. The deletion is a job of the app's runner (Storehouse.Runner), tried again when the store fails.

Deletes every file whose key starts with prefix (see list/2): an account's files when it goes, every version of a file. A thousand keys at a time where the store deletes many at once (Storehouse.Adapter.delete_many/1), one at a time where it does not. Gives {:ok, count}, the number of files deleted.

As delete_later/2, for every file whose key starts with prefix (delete_prefix/2): an account's files when it goes. Gives :ok, or {:error, :invalid_prefix}.

Writes the file with key to path, a chunk at a time (stream/2): for a tool that wants a file on the disk (libvips, ffmpeg, a zip). path is replaced; on an error, nothing is left there.

When a file (or a key) expires (expiring_key/2), or nil for one that does not.

The key of a file that expires in expires_in seconds: under expiring_prefix/0, then the days it is kept (rounded up, for a bucket's lifecycle rule of that many days), then when it expires, in Unix seconds, then key.

The prefix of the keys of expiring files (:expires_in): "expiring/", or config :storehouse, expiring_prefix: "expires/".

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).

What the store knows of the file with key, without reading it: its :bytes, its :content_type as stored (or nil), and its :checksum (or nil). A missing file gives {:error, :enoent}.

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).

The files whose keys start with prefix, as a stream of maps with their :key, :bytes and :modified_at (a DateTime), in the order of their keys: an account's files, the temporary ones, those of a file's versions. The store is asked a page at a time, as the stream is taken, and a store's error then raises Storehouse.ReadError. A key the store has that valid_key?/1 refuses is left out.

The parts the store has of an upload (presign_multipart/4), by number, with their :bytes and :checksum: what a browser that was reloaded need not send again.

The key key would have under owner's prefix: "<owner>/<key>", as store/2 and presign_upload/4 make it with :owner.

Whether key is one of owner's files: under its prefix, as :owner puts it, temporary (cache: true) or permanent. Check a key a browser sends back before you take it, so that no one claims another's file. verify_upload/2 and Storehouse.Ecto.put_file/4 check it with :owner.

Starts an upload of a very large file that the browser sends straight to the store in parts, a few at a time, each tried again on its own: a video, an archive to import, past what one PUT sends well (and S3's 5 GB). Checked as presign_upload/4 checks one (its type, size, the owner's room, the same file again), and given the parts' size

Signs the browser's PUTs of parts of an upload (presign_multipart/4): each a part number, or {number, checksum} with the part's SHA-256 in Base64, which the store then checks. Gives {:ok, [%{number: n, method: "PUT", url: …, headers: %{…}}]}, at most 1,000 at a time; a number outside 1 to 10,000 gives {:error, :invalid_part}.

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.

The bytes of the file with key, all at once: to work on a file the app keeps (read its metadata, make a smaller image of it, send it on). For a large file, stream/2 or download/3.

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.

The file with key as a stream of its bytes, :chunk_bytes at a time, each read from the store as the stream is taken: a large file sent on, into a zip, through the app, with no more of it in memory than a chunk.

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

What owner's files take in the store, counted from the store itself (list/2): {:ok, %{bytes: bytes, files: count}}. A store of many files lists them all: an app that keeps its files in records sums them there, and this checks it.

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

Whether prefix is a key or the start of one, for list/2 and delete_prefix/2: never empty, never "/".

file's variant of a preset, found or made once, however many ask for it: {:ok, %Storehouse.File{}}. See Storehouse.Variants.get/3.

The URL of file's variant of a preset: a thumbnail, one size of a srcset. See Storehouse.Variants.url/3.

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

abort_multipart(key, upload_id, opts \\ [])

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

Gives up an upload (presign_multipart/4): its parts are deleted from the store.

adapter()

@spec adapter() :: module()

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

analyze(file, steps, opts \\ [])

@spec analyze(Storehouse.File.t(), [Storehouse.Analyzer.step()], keyword()) ::
  {:ok, Storehouse.File.t()} | {:error, term()}

Runs the analysis steps of a file at once (Storehouse.Analyzer), each one's analysis kept in the file's :analysis: {:ok, file}. A step that fails keeps why, and leaves the others' results; {:error, reason} is for a file that could not be read for them.

Options

  • :force - runs the steps whose result the file has already. The default is false.
  • :max_bytes - the largest file to read for them (download/3).
  • :adapter - as for store/2.

analyze_later(file, steps, opts)

@spec analyze_later(Storehouse.File.t(), [Storehouse.Analyzer.step()], keyword()) ::
  :ok | {:error, term()}

Runs the analysis steps of a file out of the request, then gives the file with their results to the app's :then, to save them: Storehouse.Ecto.analyze_later/5 saves them in a record's column.

Storehouse.analyze_later(file, [MyApp.Analyzers.Pages],
  then: {MyApp.Documents, :analyzed, [document.id]}
)

:then is {module, function, args}, called with the file first, then args. The work is a job of the app's runner (Storehouse.Runner): Oban's when the app runs Oban, else a process of the app's. A file that cannot be read for the steps gives the function nothing, and the job is tried again.

Options

  • :then - the function (required).
  • :runner - the runner, in place of the app's (Storehouse.Runner).
  • :force, :max_bytes and :adapter - as for analyze/3.

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.

claimed_checksum(checksum)

@spec claimed_checksum(String.t() | nil) ::
  {:ok, String.t() | nil} | {:error, :invalid_checksum}

A SHA-256 as checksum/1 gives it (Base64), from hex or Base64, as a browser may send it: {:ok, checksum}, {:ok, nil} for none, or {:error, :invalid_checksum}.

Examples

iex> Storehouse.claimed_checksum("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
{:ok, "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="}

iex> Storehouse.claimed_checksum("not one")
{:error, :invalid_checksum}

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"

complete_multipart(key, upload_id, bytes, opts \\ [])

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

Puts an upload's parts together (presign_multipart/4), then checks the file as verify_upload/2 does (its type by its first bytes, its size, the owner's room, the same file again) and gives it: {:ok, file}, {:existing, file}, or an error, the file deleted.

The parts are the store's, never the browser's word: every part from 1 up, and together exactly bytes, or it gives {:error, {:missing_parts, numbers}} or {:error, {:wrong_size, bytes_the_store_has}}, and the upload stays, for the browser to send what is missing. bytes past :max_bytes, or more than S3 takes, gives {:error, :too_large} before the store is asked.

Takes the options of verify_upload/2.

copy(key, opts)

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

Copies the file with key from one store to another, under the same key: to move to another provider, keep a second copy, or bring a production account's files into development.

Storehouse.copy("2026/10/0b6…-board.png", from: MyApp.OldMedia, to: MyApp.Media)
#=> {:ok, :copied}

A file the destination has already, the same size and, where both stores keep one, the same checksum, is not copied again: {:ok, :skipped}. Its type stays as it was.

Two buckets the same credentials reach (Storehouse.S3), or two folders of this disk (Storehouse.Local), copy it themselves, with no byte through the app. Otherwise it is read a chunk at a time (stream/2) into the destination (Storehouse.Adapter.stream_open/2, else a temporary file and Storehouse.Adapter.put/3), and its checksum, computed on the way, must be the source's, else nothing is kept and the copy gives {:error, :checksum_mismatch}.

Options

  • :from and :to - the stores, two of them. Both must read files back (head/2).
  • :verify - when either store keeps no checksum of the whole file (the local disk; S3, of a file sent in parts), read both to compare a file of the same size before it is skipped. The default is false: the same size is the same file.

copy_prefix(prefix, opts)

@spec copy_prefix(String.t(), keyword()) ::
  {:ok,
   %{copied: non_neg_integer(), skipped: non_neg_integer(), failed: list()}}
  | {:error, term()}

Copies every file whose key starts with prefix (see list/2) from one store to another (copy/2), several at once. Files the destination has already are skipped, so it can be run again: after an error, or to catch up with what was written since.

Storehouse.copy_prefix("accounts/42/", from: MyApp.Media, to: MyApp.DevMedia)
#=> {:ok, %{copied: 1_204, skipped: 0, failed: []}}

"" copies every file. mix storehouse.copy runs it from the command line, with its progress.

Options

  • :from, :to and :verify - as for copy/2.
  • :max_concurrency - how many files are copied at once. The default is 4.
  • :on_progress - a function of each file's key and result ({:ok, :copied}, …), as each is done.

delete(key, opts \\ [])

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

Deletes the file with key, through the adapter (:adapter, or adapter/0), and its stored variants (Storehouse.Variants), where the store lists files.

delete_later(key, opts \\ [])

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

Deletes the file with key later, and again until the store answers: a file replaced or removed, whose record is already saved. The deletion is a job of the app's runner (Storehouse.Runner), tried again when the store fails.

Gives :ok once the job is kept, or {:error, :invalid_key} for a key valid_key?/1 refuses. Takes :adapter, as delete/2 does, and :runner.

delete_prefix(prefix, opts \\ [])

@spec delete_prefix(String.t(), keyword()) ::
  {:ok, non_neg_integer()} | {:error, term()}

Deletes every file whose key starts with prefix (see list/2): an account's files when it goes, every version of a file. A thousand keys at a time where the store deletes many at once (Storehouse.Adapter.delete_many/1), one at a time where it does not. Gives {:ok, count}, the number of files deleted.

The prefix is never empty: "" and "/" give {:error, :invalid_prefix}, as every prefix list/2 refuses does.

Options

delete_prefix_later(prefix, opts \\ [])

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

As delete_later/2, for every file whose key starts with prefix (delete_prefix/2): an account's files when it goes. Gives :ok, or {:error, :invalid_prefix}.

download(key_or_file, path, opts \\ [])

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

Writes the file with key to path, a chunk at a time (stream/2): for a tool that wants a file on the disk (libvips, ffmpeg, a zip). path is replaced; on an error, nothing is left there.

Options

  • :chunk_bytes, :max_bytes and :adapter - as for stream/2.

expires_at(key)

@spec expires_at(Storehouse.File.t() | Storehouse.Adapter.key()) :: DateTime.t() | nil

When a file (or a key) expires (expiring_key/2), or nil for one that does not.

iex> Storehouse.expires_at("expiring/1d/1792843200/exports/42.zip")
~U[2026-10-24 12:00:00Z]

iex> Storehouse.expires_at("exports/42.zip")
nil

expiring_key(key, expires_in, now \\ DateTime.utc_now())

The key of a file that expires in expires_in seconds: under expiring_prefix/0, then the days it is kept (rounded up, for a bucket's lifecycle rule of that many days), then when it expires, in Unix seconds, then key.

iex> Storehouse.expiring_key("exports/42.zip", 86_400, ~U[2026-10-23 12:00:00Z])
"expiring/1d/1792843200/exports/42.zip"

A bucket needs one lifecycle rule a day count it is given, on its prefix: "expiring/1d/" expires after 1 day, "expiring/7d/" after 7. See "Files that expire" in the Stores guide.

expiring_prefix()

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

The prefix of the keys of expiring files (:expires_in): "expiring/", or config :storehouse, expiring_prefix: "expires/".

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.

:max_bytes holds for every answer, a redirect's or an error's as much as the file's, and :timeout for the whole of it: no answer is read further than either.

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

head(key_or_file, opts \\ [])

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

What the store knows of the file with key, without reading it: its :bytes, its :content_type as stored (or nil), and its :checksum (or nil). A missing file gives {:error, :enoent}.

The size a browser declares for an upload is a claim: ask the store before reading a file it sent.

Options

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"

list(prefix, opts \\ [])

@spec list(String.t(), keyword()) :: {:ok, Enumerable.t()} | {:error, term()}

The files whose keys start with prefix, as a stream of maps with their :key, :bytes and :modified_at (a DateTime), in the order of their keys: an account's files, the temporary ones, those of a file's versions. The store is asked a page at a time, as the stream is taken, and a store's error then raises Storehouse.ReadError. A key the store has that valid_key?/1 refuses is left out.

A prefix is a key, or the start of one: "cache/", "accounts/42/", "2026/10/0b6". Another gives {:error, :invalid_prefix}.

Options

multipart_parts(key, upload_id, opts \\ [])

@spec multipart_parts(String.t(), String.t(), keyword()) ::
  {:ok,
   [
     %{
       number: pos_integer(),
       bytes: non_neg_integer(),
       checksum: String.t() | nil
     }
   ]}
  | {:error, term()}

The parts the store has of an upload (presign_multipart/4), by number, with their :bytes and :checksum: what a browser that was reloaded need not send again.

owner_key(owner, key)

@spec owner_key(String.t() | non_neg_integer(), Storehouse.Adapter.key()) ::
  {:ok, Storehouse.Adapter.key()} | {:error, :invalid_owner}

The key key would have under owner's prefix: "<owner>/<key>", as store/2 and presign_upload/4 make it with :owner.

Examples

iex> Storehouse.owner_key("accounts/42", "2026/10/a.png")
{:ok, "accounts/42/2026/10/a.png"}

iex> Storehouse.owner_key("../42", "a.png")
{:error, :invalid_owner}

owns?(owner, key)

Whether key is one of owner's files: under its prefix, as :owner puts it, temporary (cache: true) or permanent. Check a key a browser sends back before you take it, so that no one claims another's file. verify_upload/2 and Storehouse.Ecto.put_file/4 check it with :owner.

Examples

iex> Storehouse.owns?("accounts/42", "accounts/42/2026/10/a.png")
true

iex> Storehouse.owns?("accounts/42", "accounts/421/2026/10/a.png")
false

iex> Storehouse.owns?(42, "cache/42/2026/10/a.png")
true

iex> Storehouse.owns?("accounts/42", "accounts/42/../7/a.png")
false

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

@spec presign_multipart(String.t(), String.t() | nil, non_neg_integer(), keyword()) ::
  {:ok,
   %{
     key: String.t(),
     upload_id: String.t(),
     part_bytes: pos_integer(),
     parts: pos_integer()
   }}
  | {:existing, Storehouse.File.t()}
  | {:error, term()}

Starts an upload of a very large file that the browser sends straight to the store in parts, a few at a time, each tried again on its own: a video, an archive to import, past what one PUT sends well (and S3's 5 GB). Checked as presign_upload/4 checks one (its type, size, the owner's room, the same file again), and given the parts' size:

{:ok, %{key: key, upload_id: id, part_bytes: 16_777_216, parts: 128}} =
  Storehouse.presign_multipart("film.mov", "video/quicktime", 2_147_483_648, owner: owner)

Then presign_parts/4 signs the parts, multipart_parts/3 says which the store has (to go on after a reload), and complete_multipart/4 puts them together and checks the file as verify_upload/2 does. abort_multipart/3 gives it up. Storehouse.Plug.Multipart is the same as JSON endpoints, and the JavaScript client sends large files through it.

Options

Those of presign_upload/4, and:

  • :part_bytes - the size of each part but the last, at least 5 MiB. The default is 16 MiB, or more for a file of more than 10,000 such parts (S3's most).

The adapter must take multipart uploads (Storehouse.Adapter.multipart_start/2): Storehouse.S3 does; another gives {:error, :not_supported}.

presign_parts(key, upload_id, parts, opts \\ [])

@spec presign_parts(
  String.t(),
  String.t(),
  [pos_integer() | {pos_integer(), String.t()}],
  keyword()
) :: {:ok, [map()]} | {:error, term()}

Signs the browser's PUTs of parts of an upload (presign_multipart/4): each a part number, or {number, checksum} with the part's SHA-256 in Base64, which the store then checks. Gives {:ok, [%{number: n, method: "PUT", url: …, headers: %{…}}]}, at most 1,000 at a time; a number outside 1 to 10,000 gives {:error, :invalid_part}.

Options

  • :bytes and :part_bytes - the upload's size and its parts' (as presign_multipart/4 gave them): each URL then takes a part of its size only, and a number past the upload's last part gives {:error, :invalid_part}. Storehouse.Plug.Multipart gives them from the upload's token.
  • :expires_in - how long each URL is valid, in seconds. The default is an hour.
  • :adapter - as for presign_multipart/4.

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()
   }}
  | {:existing, Storehouse.File.t()}
  | {: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.
  • :owner and :room, as for store/2: the key under the owner's prefix, and a file larger than the room left refused at once, with {:error, :storage_full}.
  • :checksum - the file's SHA-256, as the browser read it (hex, or Base64): the upload is signed with it, so the store takes only those bytes. An invalid one gives {:error, :invalid_checksum}.
  • :existing - as for store/2, with :checksum: a file the app has already gives {:existing, file}, and nothing is uploaded.
  • :cache_control - as for store/2: signed, so the browser sends it, and the store keeps it.
  • :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

read(key_or_file, opts \\ [])

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

The bytes of the file with key, all at once: to work on a file the app keeps (read its metadata, make a smaller image of it, send it on). For a large file, stream/2 or download/3.

Its size is asked first (head/2): a file larger than :max_bytes gives {:error, {:too_large, bytes}} before a byte of it is read, and no more than that size is ever read.

Options

  • :max_bytes - the largest file to read. The default is no limit.
  • :adapter - as for head/2.

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

  • :expires_in - in seconds. The default is 300. An expiring file's URL (expiring_key/2) expires with it, at the latest; one expired already gives {:error, :expired}.
  • :disposition - "inline" or "attachment" (a download).
  • :filename - the name of the download.
  • :adapter - as for store/2. The adapter must sign URLs (Storehouse.Adapter.signed_url/2): Storehouse.Local and Storehouse.S3 do; another gives {:error, :not_supported}.

store(path, opts \\ [])

@spec store(Path.t(), keyword()) ::
  {:ok, Storehouse.File.t()}
  | {:existing, 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.
  • :owner - whose file it is, for an app whose files belong to someone (an account, a workspace): its key is put under the owner's prefix, "<owner>/…" (owner_key/2). A string of key segments ("accounts/42") or an integer.
  • :room - how many bytes the owner may still store: a larger file gives {:error, :storage_full}, and is not stored. The default is no limit.
  • :existing - a function of a checksum (checksum/1's, Base64) that gives the file the app has already with it, or nil: the same file stored again is then that file, {:existing, file}, and nothing is stored. With :owner, a file of another owner is never given back. See "The same file again" in the Stores guide.
  • :analyze - analysis steps (Storehouse.Analyzer) to run once the file is stored, from path, with no download: the file comes with their results. A step that fails keeps why, and the file is stored either way. For work out of the request, analyze_later/3.
  • :metadata - the file's own (Storehouse.Metadata): a caption, a credit. Metadata that JSON cannot hold gives {:error, {:invalid_metadata, path}}, and nothing is stored.
  • :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.
  • :expires_in - seconds after which the file is deleted: an export, a report, a PDF to download for a week. Its key is an expiring one (expiring_key/2): a bucket's lifecycle rule deletes it, or Storehouse.Sweeper does, and signed_url/2 never signs it past then. Not with :cache.
  • :cache_control - the Cache-Control the store serves the file with, in place of its own (Storehouse.S3's :cache_control).
  • :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.

stream(key_or_file, opts \\ [])

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

The file with key as a stream of its bytes, :chunk_bytes at a time, each read from the store as the stream is taken: a large file sent on, into a zip, through the app, with no more of it in memory than a chunk.

Its size is asked at once (head/2), so a missing file gives {:error, :enoent} here and a file larger than :max_bytes {:error, {:too_large, bytes}}. A store's error while the stream is taken raises Storehouse.ReadError.

Options

  • :chunk_bytes - the size of each read. The default is 4 MiB.
  • :max_bytes - the largest file to stream. The default is no limit.
  • :adapter - as for head/2.

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).

usage(owner, opts \\ [])

@spec usage(String.t() | non_neg_integer(), keyword()) ::
  {:ok, %{bytes: non_neg_integer(), files: non_neg_integer()}}
  | {:error, term()}

What owner's files take in the store, counted from the store itself (list/2): {:ok, %{bytes: bytes, files: count}}. A store of many files lists them all: an app that keeps its files in records sums them there, and this checks it.

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

valid_prefix?(prefix)

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

Whether prefix is a key or the start of one, for list/2 and delete_prefix/2: never empty, never "/".

Examples

iex> Storehouse.valid_prefix?("accounts/42/")
true

iex> Storehouse.valid_prefix?("2026/10/0b6")
true

iex> Storehouse.valid_prefix?("/")
false

iex> Storehouse.valid_prefix?("../")
false

variant(file, preset, opts \\ [])

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

file's variant of a preset, found or made once, however many ask for it: {:ok, %Storehouse.File{}}. See Storehouse.Variants.get/3.

variant_url(file, preset, opts \\ [])

@spec variant_url(Storehouse.File.t() | String.t(), atom(), keyword()) :: String.t()

The URL of file's variant of a preset: a thumbnail, one size of a srcset. See Storehouse.Variants.url/3.

verify_upload(key, opts \\ [])

@spec verify_upload(Storehouse.Adapter.key(), keyword()) ::
  {:ok, Storehouse.File.t()}
  | {:existing, 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.
  • :metadata - as for store/2.
  • :owner - the owner the file must belong to (owns?/2): a key under another's prefix gives {:error, :not_owner}, and the file there is left as it is.
  • :room - the bytes the owner may still store, against the size the store gives (not the one the browser declared): a larger file gives {:error, :storage_full}, and is deleted.
  • :existing - as for store/2, with the checksum the store keeps: a file the app has already (the same file uploaded twice at once) gives {:existing, file}, and the copy just uploaded is deleted.
  • :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.