# `Storehouse`
[🔗](https://github.com/hattoriai/storehouse/blob/v0.2.0/lib/storehouse.ex#L1)

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)

# `abort_multipart`

```elixir
@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`

```elixir
@spec adapter() :: module()
```

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

# `analyze`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@spec cached?(Storehouse.File.t() | Storehouse.Adapter.key()) :: boolean()
```

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

# `checksum`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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 (`c:Storehouse.Adapter.stream_open/2`, else a
temporary file and `c: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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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
(`c: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

  * `:adapter` - as for `list/2`.

# `delete_prefix_later`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@spec expiring_key(Storehouse.Adapter.key(), pos_integer(), DateTime.t()) ::
  Storehouse.Adapter.key()
```

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`

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

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

# `fetch`

```elixir
@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?`

```elixir
@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`

```elixir
@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

  * `:adapter` - as for `store/2`. The adapter must read files
    (`c:Storehouse.Adapter.head/1` and `c:Storehouse.Adapter.read_range/3`):
    `Storehouse.Local` and `Storehouse.S3` do; another gives
    `{:error, :not_supported}`.

# `key`

```elixir
@spec key(String.t(), String.t()) :: Storehouse.Adapter.key()
```

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`

```elixir
@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

  * `:adapter` - as for `store/2`. The adapter must list files
    (`c:Storehouse.Adapter.list/1`): `Storehouse.Local` and
    `Storehouse.S3` do; another gives `{:error, :not_supported}`.

# `multipart_parts`

```elixir
@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`

```elixir
@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?`

```elixir
@spec owns?(String.t() | non_neg_integer(), Storehouse.Adapter.key()) :: boolean()
```

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`

```elixir
@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 (`c:Storehouse.Adapter.multipart_start/2`):
`Storehouse.S3` does; another gives `{:error, :not_supported}`.

# `presign_parts`

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

Signs the browser's `PUT`s 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`

```elixir
@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 (`c:Storehouse.Adapter.presign_upload/3`): `Storehouse.Local`
    and `Storehouse.S3` do; another gives `{:error, :not_supported}`.

# `promote`

```elixir
@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

  * `:adapter` - as for `store/2`. The adapter copies files in the store
    (`c:Storehouse.Adapter.copy/2`): `Storehouse.Local` and
    `Storehouse.S3` do; another gives `{:error, :not_supported}`.
  * `:keep_cached` - `true` keeps the temporary file. The default is
    `false`.

# `read`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

```elixir
@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
    (`c:Storehouse.Adapter.signed_url/2`): `Storehouse.Local` and
    `Storehouse.S3` do; another gives `{:error, :not_supported}`.

# `store`

```elixir
@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`

```elixir
@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`

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

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

# `usage`

```elixir
@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?`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
