# `Kotoba.Storage`
[🔗](https://github.com/hattoriai/kotoba/blob/v0.5.0/lib/kotoba/storage.ex#L1)

The behaviour of a file store for attachments.

`Kotoba.Live.consume_uploads/4` stores each uploaded file through the
configured adapter, and puts the URL that the adapter returns in the
attachment node:

    config :kotoba, storage: Kotoba.Storage.Local

The default adapter is `Kotoba.Storage.Local`.

## 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 (`Kotoba.Attachments.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.

## Serving the files

An uploaded file is content from a user. Serve it with
`X-Content-Type-Options: nosniff`, with its checked content type, and
with `Content-Disposition: attachment` for every type that is not an
image, a PDF or a video (see `Kotoba.Attachments.inline?/1`).
`Kotoba.Storage.Local.Plug` does this for the local adapter; the example
below does it for S3.

## Another store

To keep the files somewhere else (S3, another object store, a database),
write a module with the three callbacks and name it in the config, or
give it per call with the `:storage` option of
`Kotoba.Live.consume_uploads/4`. For example, with `ExAws.S3`:

    defmodule MyApp.KotobaStorage do
      @behaviour Kotoba.Storage

      @bucket "my-app-uploads"

      @impl true
      def put(key, path, meta) do
        disposition =
          if Kotoba.Attachments.inline?(meta.content_type), do: "inline", else: "attachment"

        path
        |> ExAws.S3.Upload.stream_file()
        |> ExAws.S3.upload(@bucket, key,
          content_type: meta.content_type,
          content_disposition: disposition
        )
        |> ExAws.request()
        |> case do
          {:ok, _response} -> {:ok, url(key)}
          {:error, reason} -> {:error, reason}
        end
      end

      @impl true
      def url(key), do: "https://#{@bucket}.s3.amazonaws.com/" <> key

      @impl true
      def delete(key) do
        case ExAws.request(ExAws.S3.delete_object(@bucket, key)) do
          {:ok, _response} -> :ok
          {:error, reason} -> {:error, reason}
        end
      end
    end

    config :kotoba, storage: MyApp.KotobaStorage

The URL goes into the stored document, so it must stay valid: a public
or CDN URL, or a path in the app (for example `"/files/" <> key`) whose
controller checks access and redirects to a short-lived signed URL.
The file first comes to the LiveView (a normal LiveView upload); the
adapter then sends it on.

# `key`

```elixir
@type key() :: String.t()
```

A storage key, for example `"2026/09/0b6…-cat.png"`.

# `meta`

```elixir
@type meta() :: %{
  optional(:name) =&gt; String.t(),
  optional(:content_type) =&gt; String.t(),
  optional(:bytes) =&gt; non_neg_integer()
}
```

What Kotoba knows about the file: `:name` (the client's file name, for
display only, with control and format characters removed),
`:content_type` (checked on the server, see `Kotoba.Attachments`) and
`:bytes`.

# `delete`

```elixir
@callback delete(key()) :: :ok | {:error, term()}
```

Deletes the file with `key`. A key with no file gives `:ok`.

# `put`

```elixir
@callback put(key(), path :: Path.t(), meta()) ::
  {:ok, url :: String.t()} | {:error, term()}
```

Stores the file at `path` under `key`, and returns its URL.

The file at `path` is a temporary file: the adapter copies it, and must
not keep a reference to it.

# `url`

```elixir
@callback url(key()) :: String.t()
```

Returns the URL of the file with `key`.

# `adapter`

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

Returns the configured adapter (`config :kotoba, storage:`), by default
`Kotoba.Storage.Local`.

# `key`

```elixir
@spec key(String.t(), String.t()) :: 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 = Kotoba.Storage.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" |> Kotoba.Storage.key("application/octet-stream") |> Path.extname()
    ".bin"

# `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> Kotoba.Storage.safe_name("résumé 2026.pdf", "application/pdf")
    "r_sum_2026.pdf"

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

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

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

# `valid_key?`

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

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

## Examples

    iex> Kotoba.Storage.valid_key?("2026/09/a-cat.png")
    true

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

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

---

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