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.LocalThe 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.KotobaStorageThe 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
@type key() :: String.t()
A storage key, for example "2026/09/0b6…-cat.png".
@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
Deletes the file with key. A key with no file gives :ok.
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.
Returns the URL of the file with key.
Functions
@spec adapter() :: module()
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).
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"
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"
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