Kotoba.Storage behaviour (Kotoba v0.5.0)

Copy Markdown View Source

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.

Summary

Types

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

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.

Callbacks

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

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

Returns the URL of the file with key.

Functions

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

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

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.

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

Types

key()

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

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

meta()

@type meta() :: %{
  optional(:name) => String.t(),
  optional(:content_type) => String.t(),
  optional(:bytes) => 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.

Callbacks

delete(key)

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

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

put(key, path, meta)

@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(key)

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

Returns the URL of the file with key.

Functions

adapter()

@spec adapter() :: module()

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

key(name, content_type)

@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(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> 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?(key)

@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