# `Storehouse`
[🔗](https://github.com/hattoriai/storehouse/blob/v0.1.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`.

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)

# `adapter`

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

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

# `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.

# `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"

# `delete`

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

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

# `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.

# `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

# `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"

# `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()
   }}
  | {: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 (`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`.

# `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.
  * `: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()} | {: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`

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

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

# `verify_upload`

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

---

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