This guide stores files from a Phoenix app on a disk: uploads from a LiveView form with Fil.LiveView, direct uploads
from the browser, uploads to a controller, and links to the stored files. The examples store them on disk, a disk with
Fil.Plugin.ContentType attached, such as the uploads disk from the installation guide. Options that
are compiled, such as an Ecto field's or a plug's disk:, take a capture of the function that builds it instead
(&MyApp.Storage.uploads/0), see Fil.Disk.resolve/1.
Fil.LiveView needs Phoenix LiveView 1.2 and Phoenix 1.8. Both are optional dependencies of Fil, so an app with
them needs nothing else.
LiveView uploads
Allowing the upload
Allow the upload in mount/3 as usual, with the extensions it takes in accept::
def mount(_params, _session, socket) do
{:ok, allow_upload(socket, :avatar, accept: ~w(.jpg .jpeg .png), max_file_size: 5_000_000)}
endLiveView accepts a file when its extension or its type matches accept:, and the type comes from the browser. A file
named evil.html that the browser sends as image/png passes. Fil.LiveView checks the extension of the path it
writes against the extensions accept: allows, the ones it lists and those of its exact types (image/png allows
.png), and refuses that file. A wildcard such as image/* allows no extension of its own: with only wildcards the
extension isn't checked, and next to extensions (~w(.pdf image/*)) it lets no image through. So list the extensions
a wildcard stands for too, in accept: or in extensions:.
The upload field
Fil.LiveView.upload_field/1 renders the upload part of a form: a drop zone with the file input, a row for each picked
file with a preview, its progress and a cancel button, and a row for each file the record has, with a remove button.
Import it in the html_helpers of your web module, next to the core components:
import MyAppWeb.CoreComponents
import Fil.LiveView, only: [upload_field: 1]This form edits a product with up to five photos, stored in a {:array, Fil.Ecto.Ref} field (see
Storing the files for Fil.Ecto.Ref):
schema "products" do
field :name, :string
field :photos, {:array, Fil.Ecto.Ref}, disk: &MyApp.Storage.uploads/0, default: []
endThe LiveView keeps the photos the product keeps in the :photos assign. Removing one drops it from that list, and
nothing is deleted before the product is saved:
def mount(%{"id" => id}, _session, socket) do
product = Catalog.get_product!(id)
{:ok,
socket
|> assign(product: product, photos: product.photos, photo_errors: [])
|> assign(:form, to_form(Catalog.change_product(product)))
|> allow_upload(:photos, accept: ~w(.jpg .jpeg .png), max_entries: 5, max_file_size: 5_000_000)}
end<.form for={@form} id="product-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} label="Name" />
<.upload_field
upload={@uploads.photos}
existing={@photos}
field={@form[:photos]}
errors={@photo_errors}
label="Photos"
translate={&translate_error/1}
/>
<.button phx-disable-with="Saving...">Save</.button>
</.form>LiveView's max_entries: counts only the picked files, so the limit on the product is a validate_length/3 in the
changeset. validate/2 checks it with the photos the product would have after the save, the kept ones and the picked
ones LiveView accepted, and the component shows the error under the files. It also clears the error of the last save:
def handle_event("validate", %{"product" => params}, socket), do: {:noreply, validate(socket, params)}
defp validate(socket, params) do
changeset =
socket.assigns.product
|> change_photos(params, planned_photos(socket))
|> Map.put(:action, :validate)
assign(socket, form: to_form(changeset), photo_errors: [])
end
defp change_photos(product, params, photos) do
product
|> Catalog.change_product(params)
|> Ecto.Changeset.put_change(:photos, photos)
|> Ecto.Changeset.validate_length(:photos, max: 5)
end
# `entry_ref/3` returns the ref an entry will be written to, without writing anything.
defp planned_photos(socket) do
picked =
for entry <- socket.assigns.uploads.photos.entries, entry.valid? do
Fil.LiveView.entry_ref(disk, entry)
end
socket.assigns.photos ++ picked
endThe cancel and remove buttons send "cancel-upload" and "remove-file" to the LiveView, with the upload's name in
"upload". Fil.LiveView.cancel_upload/2 cancels the entry, and ignores one that's gone already (after a double click,
say), where LiveView's cancel_upload/3 raises. A click doesn't send the form's phx-change, so both handlers call
validate/2 with the form's last params, and a count error goes away once the user removed enough photos:
def handle_event("cancel-upload", params, socket) do
socket = Fil.LiveView.cancel_upload(socket, params)
{:noreply, validate(socket, socket.assigns.form.params)}
end
def handle_event("remove-file", %{"upload" => "photos", "path" => path}, socket) do
socket = update(socket, :photos, fn photos -> Enum.reject(photos, &(&1.path == path)) end)
{:noreply, validate(socket, socket.assigns.form.params)}
endsave checks the same changeset again before it consumes the upload, so a product with too many photos keeps the
picked files. Then it stores the new files and puts the kept and the new ones into the changeset.
Fil.Ecto.Ref.removed/2 returns the photos the change drops, which are deleted once the update is committed:
def handle_event("save", %{"product" => params}, socket) do
%{product: product, photos: kept} = socket.assigns
planned = change_photos(product, params, planned_photos(socket))
with true <- planned.valid?,
{:ok, new} <- Fil.LiveView.consume_uploaded_entries(socket, :photos, disk) do
save_product(socket, change_photos(product, params, kept ++ new), new)
else
false -> {:noreply, assign(socket, :form, to_form(planned, action: :update))}
{:error, error} -> {:noreply, assign(socket, :photo_errors, [error])}
end
end
defp save_product(socket, changeset, new) do
removed = Fil.Ecto.Ref.removed(changeset, :photos)
case Repo.update(changeset) do
{:ok, product} ->
Enum.each(removed, &Fil.rm/1)
{:noreply,
socket
|> assign(product: product, photos: product.photos, photo_errors: [])
|> assign(:form, to_form(Catalog.change_product(product)))}
{:error, changeset} ->
Enum.each(new, &Fil.rm/1)
{:noreply, assign(socket, :form, to_form(changeset))}
end
endA failed update deletes the new files again. Their entries are consumed by then, so the user picks the files again.
A failed consume keeps every entry and writes nothing, and the component shows its error from photo_errors until the
next change.
A single file works the same with a Fil.Ecto.Ref field: existing={@avatar} takes the ref or nil, and the save
puts List.first(new) || kept into the changeset. The component hides a single stored file while a new one is picked,
because saving replaces it. Every remove button sends the name of its upload, so a second field on the page gets a
clause of its own:
def handle_event("remove-file", %{"upload" => "avatar"}, socket), do: {:noreply, assign(socket, :avatar, nil)}With auto_upload: true and a progress: callback that consumes each entry when it's done (see
below), a consumed entry leaves the list, so append its ref to the list you
pass as existing.
Errors and translations
The component shows the errors of each picked file (too large, not accepted), of the upload (too many files), of the
field once files are picked or the form was submitted, and the ones you pass in errors. Fil.LiveView.upload_error/2
gives each a text as {msgid, bindings}, the shape of Ecto's errors, and translate turns it into text.
translate={&translate_error/1} looks the texts up in the errors domain of the app's Gettext backend, as for Ecto's
errors. The component's labels ("Cancel", "Remove", "or drop files here") go through it too. mix gettext.extract
doesn't find texts built in a dependency, so copy the msgids from the Fil.LiveView.upload_field/1 docs into
priv/gettext/errors.pot. Without translate, the component fills in the bindings and translates nothing.
The same function gives a flash its text:
{:error, error} ->
message =
error
|> Fil.LiveView.upload_error()
|> translate_error()
{:noreply, put_flash(socket, :error, message)}Styling
The default classes are daisyUI 5 classes, as in the core_components.ex Phoenix 1.8
generates. Tailwind builds only the classes it finds in its sources, and the generated assets/css/app.css doesn't
look into deps, so add a line for Fil next to the other @source lines:
@source "../../deps/fil/lib/fil/live_view";Without it, the file input, the drop zone and the error state have no styles.
Each part takes a class attribute that replaces its default, as class does on the generated components:
class, label_class, dropzone_class, input_class, error_class, hint_class, list_class, entry_class,
progress_class, button_class and message_class. The docs of each show its default, so to add a class, copy the
default and append to it. The <div> around the content of a row, the preview, the name and the size, and the <div>
around the error messages take no class attribute, and have only Tailwind utilities.
An app with a design of its own sets the classes once, in a wrapper in its core_components.ex that also sets
translate. A wrapper declares the slots it forwards:
attr :upload, Phoenix.LiveView.UploadConfig, required: true
attr :existing, :any, default: []
attr :field, Phoenix.HTML.FormField, default: nil
attr :errors, :list, default: []
attr :label, :string, default: nil
slot :entry
slot :file
def upload_field(assigns) do
~H"""
<Fil.LiveView.upload_field
upload={@upload}
existing={@existing}
field={@field}
errors={@errors}
label={@label}
translate={&translate_error/1}
dropzone_class="flex flex-col gap-2 rounded-lg border border-zinc-300 p-3"
button_class="rounded border border-zinc-300 px-2 py-1 text-xs"
>
<:entry :for={entry <- @entry} :let={upload_entry}>{render_slot(entry, upload_entry)}</:entry>
<:file :for={file <- @file} :let={ref}>{render_slot(file, ref)}</:file>
</Fil.LiveView.upload_field>
"""
endThe wrapper takes the name upload_field, so leave out the import of Fil.LiveView's in that case.
Without daisyUI, its class names and theme colours do nothing, and the Tailwind utilities still apply. Tailwind's
preflight strips the look of the file input and the buttons, so pass input_class and button_class too.
The :file slot replaces the content of a stored file's row, and :entry that of a picked file's row. The row itself
stays, with its buttons, progress bar and errors. Stored paths are UUIDs, so a form with images shows a thumbnail. The
component calls no Fil function while it renders, so the URLs are built before. A helper that mount/3 and the save
call instead of assigning :photos keeps them in step with the photos:
defp assign_photos(socket, photos) do
urls = Map.new(photos, &{&1.path, Fil.signed_url!(&1, expires_in: 3600)})
assign(socket, photos: photos, photo_urls: urls)
end<.upload_field upload={@uploads.photos} existing={@photos} label="Photos">
<:file :let={photo}>
<img src={@photo_urls[photo.path]} alt="" width="48" height="48" class="size-12 rounded object-cover" />
</:file>
</.upload_field>A signed URL works for expires_in seconds, so a form that stays open longer shows broken thumbnails until it's
loaded again. Removing a photo needs no new URLs, and appending one after an auto upload goes through the helper too.
Your own markup
The component is optional. A template of your own uses LiveView's components (see
Uploads in its docs), and Fil.LiveView.upload_error/2 for the
texts:
<form id="avatar-form" phx-change="validate" phx-submit="save">
<.live_file_input upload={@uploads.avatar} />
<div :for={entry <- @uploads.avatar.entries}>
<.live_img_preview entry={entry} width="80" />
<progress value={entry.progress} max="100">{entry.progress}%</progress>
<button type="button" phx-click="cancel" phx-value-ref={entry.ref}>Cancel</button>
<p :for={error <- upload_errors(@uploads.avatar, entry)}>
{translate_error(Fil.LiveView.upload_error(error, @uploads.avatar))}
</p>
</div>
<button type="submit">Save</button>
</form>def handle_event("validate", _params, socket), do: {:noreply, socket}
def handle_event("cancel", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :avatar, ref)}
endStoring the files
Fil.LiveView.consume_uploaded_entries/4 writes each file to the disk and consumes its entry. Here it is on its own,
for the avatar form above:
def handle_event("save", _params, socket) do
user = socket.assigns.current_user
case Fil.LiveView.consume_uploaded_entries(socket, :avatar, disk,
path: &"avatars/#{user.id}/#{Fil.LiveView.filename(&1)}"
) do
{:ok, [avatar]} ->
{:ok, user} = Accounts.update_avatar(user, avatar)
{:noreply, assign(socket, :current_user, user)}
{:ok, []} ->
{:noreply, socket}
{:error, error} ->
{:noreply, put_flash(socket, :error, avatar_error(error))}
end
end
defp avatar_error(%Fil.InvalidRequestError{reason: :extension}), do: "Only .jpg and .png files can be uploaded."
defp avatar_error(%Fil.AlreadyExistsError{}), do: "This file was uploaded already."
defp avatar_error(error) do
Logger.error("Avatar upload failed: " <> Exception.message(error))
"The upload failed. Please try again."
endAn error's message names the disk and the path in storage, so it's for logs, not for users. avatar_error/1 picks a
text for the errors a user can do something about, from the error struct and its :reason, and logs the rest (with
require Logger in the module).
The disk is a function, so it's built when the form is submitted, from the config of the environment the app runs in.
A Fil.Ref for a directory works as well, and the paths are then relative to it.
path: decides where each file goes. Fil.LiveView.filename/1 is the entry's UUID with the extension of the uploaded
file, such as 0b2e8b8e-4a0a-4c1e-9d1e-2f6f4e0c7a11.png, and it's the default. Don't build paths from
entry.client_name: the browser sends it, so it can be ../../config.exs, a name another user already has, or
index.html.
The result is a list of refs in the order of the file input. A Fil.Ecto.Ref field stores a ref: the column holds the
path, and loading the user gives a ref on the field's disk again.
schema "users" do
field :avatar, Fil.Ecto.Ref, disk: &MyApp.Storage.uploads/0
endThe field takes refs, never strings from params, so leave it out of cast/4 and put the ref with put_change/3.
Fil.Ecto.Ref.removed/2 returns the avatar the change replaces, which update_avatar/2 deletes once the update is
committed:
def update_avatar(user, avatar) do
changeset =
user
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(:avatar, avatar)
removed = Fil.Ecto.Ref.removed(changeset, :avatar)
with {:ok, user} <- Repo.update(changeset) do
Enum.each(removed, &Fil.rm/1)
{:ok, user}
end
endThe Fil.Ecto.Ref docs show the migration for each database, and {:array, Fil.Ecto.Ref} for several files in one
field.
Fil.LiveView never replaces a file, unless you pass if_exists: :overwrite. With UUIDs in the path that doesn't
happen anyway, but a path: that returns a name that's taken gets a Fil.AlreadyExistsError instead of replacing
that file. That includes two files of one submit with the same name: the second one fails, and then the first one is
deleted again with the rest, as below.
A failed write returns the error and keeps every entry in the upload. Files written before the error are deleted again, so the user can fix what's wrong and submit the form again, or cancel an entry. To show the error next to the form instead of in a flash, assign it:
{:error, error} -> {:noreply, assign(socket, :upload_error, avatar_error(error))}The files are written in the LiveView process, as with LiveView's own consume_uploaded_entries/3, so a large file
blocks the LiveView until it's stored. Direct uploads don't.
Storing each file when it's uploaded
With auto_upload: true and a progress: callback, each file can be stored as soon as it's uploaded, with
Fil.LiveView.consume_uploaded_entry/4:
def mount(_params, _session, socket) do
{:ok,
allow_upload(socket, :avatar,
accept: ~w(.jpg .jpeg .png),
auto_upload: true,
progress: &handle_progress/3
)}
end
defp handle_progress(:avatar, entry, socket) when entry.done? do
case Fil.LiveView.consume_uploaded_entry(socket, entry, disk) do
{:ok, avatar} -> {:noreply, assign(socket, :avatar, avatar.path)}
{:error, error} -> {:noreply, put_flash(socket, :error, avatar_error(error))}
end
end
defp handle_progress(:avatar, _entry, socket), do: {:noreply, socket}A failed write keeps the entry, but the upload is finished, so the progress callback doesn't run for it again. Show the error and let the user cancel the entry and pick the file again.
Testing
Tests with a memory disk need nothing more than the Fil.Adapter.Memory.checkout/0 from the
installation guide: the LiveView uses the store of the test that started it. Upload with
Phoenix.LiveViewTest, then look at the disk:
test "stores the avatar", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/settings/avatar")
avatar = file_input(view, "#avatar-form", :avatar, [%{name: "me.png", content: "png", type: "image/png"}])
render_upload(avatar, "me.png")
view
|> form("#avatar-form")
|> render_submit()
assert {:ok, [_avatar]} = Fil.ls(disk, "avatars/#{user.id}")
endDirect uploads
With a direct upload, the browser sends each file straight to the disk, and the content never passes through the
LiveView. On S3 that's a presigned PUT. Local and memory disks get the file through Fil.Plug in your endpoint.
Fil.LiveView.external/2 returns the function for external::
def mount(_params, _session, socket) do
user = socket.assigns.current_user
{:ok,
allow_upload(socket, :avatar,
accept: ~w(.jpg .jpeg .png),
max_file_size: 20_000_000,
external: Fil.LiveView.external(disk, path: &"avatars/#{user.id}/#{Fil.LiveView.filename(&1)}")
)}
endFor each file it builds the path, checks the extension as above, and signs an upload URL with Fil.signed_url/3. The
URL is bound to the content type of the path, the size the browser reported and if_exists: :error (see
Uploads), so it uploads one file, once, and can't replace the file later. With
if_exists: :overwrite, it writes the file again on every request until it expires, also after the consume. A refused
extension shows up as {:external_metadata_failure, %{reason: :extension}} in upload_errors/2, any other error as
%{reason: :error}, and the error itself goes to the log. A failed upload is LiveView's :external_client_failure.
The form and the save handler from Storing the files stay the same, with the same disk.
Fil.LiveView.consume_uploaded_entries/4 then checks that each file arrived at the path external/2 signed, which
LiveView keeps on the server, instead of writing it, and update_avatar/2 stores the ref as before. The consume's own
path:, extensions: and if_exists: don't apply to direct uploads, so another path: there doesn't move the file.
A file that never arrived gives a Fil.NotFoundError, and the entry stays, as with any failed consume. A failed
consume doesn't delete the files that did arrive, so the form can be submitted again.
The browser only reports that the upload is done, and a modified client can report one it never made. So path: has
to give each entry a path of its own, as Fil.LiveView.filename/1 does. With &"docs/#{&1.client_name}", a user could
claim a file another user uploaded. The consume refuses a file that's older than the upload URL (with 30 seconds for
clock differences) with a Fil.AlreadyExistsError and reason: :before_upload, and one that's larger than
max_file_size with reason: :too_large. It leaves both files where they are, because they may belong to someone
else.
The uploader
The browser side is the Fil uploader, which Fil compiles as colocated JS. Pass it to the LiveSocket in
assets/js/app.js:
import {uploaders} from "phoenix-colocated/fil"
const liveSocket = new LiveSocket("/live", Socket, {hooks: {...colocatedHooks}, uploaders, params: {_csrf_token}})An app with uploaders of its own merges them: uploaders: {...uploaders, S3: myS3Uploader}. Apps generated by Phoenix
1.8 find phoenix-colocated/fil already, because their esbuild config has the build directory in NODE_PATH. An older
app adds Mix.Project.build_path() to NODE_PATH in its esbuild config, and an app with another bundler points it at
_build/<env>/phoenix-colocated.
The uploader sends each file with XMLHttpRequest, with the headers the URL was signed with, and reports its
progress. Cancelling an entry aborts its request. An upload the storage refuses calls entry.error(), which LiveView
shows as :external_client_failure. Without auto_upload, LiveView then clears the file input, and the user picks
the file again.
Local and memory disks
Fil.Plugin.URL with a secret: signs the URLs and Fil.Plug takes the uploads, as in
Signed URLs in the installation guide. The uploads go to your own endpoint, so they need
no CORS and no CSRF token. Fil.Plug takes uploads up to its max_body_size:, 100 MiB by default, so set it to at
least the upload's max_file_size:
# lib/my_app_web/endpoint.ex
plug Fil.Plug, at: "/uploads", disk: &MyApp.Storage.uploads/0, max_body_size: 20_000_000A disk without Fil.Plugin.URL can't sign URLs, so external/2 gives every file the error %{reason: :error}. For
tests with direct uploads, add the plugin to the memory disk in config/test.exs too. render_upload/3 doesn't send
the file of a direct upload, so a test that consumes it has to write the file itself.
S3
S3 checks the signature, the content type and the size of each upload, and answers 412 when the file exists. The
browser sends the file to the bucket from your app's page, so the bucket needs a CORS rule that allows PUT from your
app's origin with the two headers the uploader sets:
{
"CORSRules": [
{
"AllowedOrigins": ["https://my-app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type", "if-none-match"],
"MaxAgeSeconds": 3600
}
]
}aws s3api put-bucket-cors --bucket my-app-uploads --cors-configuration file://cors.json
A disk with a public_endpoint: signs the URLs for it, so the browser can reach a bucket behind another name than the
one the app uses.
Plugins and thumbnails
A presigned PUT goes to S3 directly, so none of the disk's plugins run. Fil.Plugin.ContentType isn't needed, because
the content type is bound into the URL. Plugins that change the content (encryption, compression) don't work with
direct uploads to S3, and Fil.Plugin.Thumbnails makes no thumbnails. For those, attach Fil.Plugin.Thumbnails with
mode: :manual and make them from the refs the consume returns, in the save handler:
{:ok, [avatar]} ->
Fil.Plugin.Thumbnails.generate(avatar)
{:ok, user} = Accounts.update_avatar(user, avatar)
{:noreply, assign(socket, :current_user, user)}Uploads through Fil.Plug are written with Fil.write/4, so the plugins run there. A disk with Fil.Plugin.URL and a
secret: sends its uploads through Fil.Plug, on S3 too.
Files nobody consumes
A file whose form is never submitted stays on the disk, and so does a file the consume refused. Clean those up with a job that runs every day and deletes the files that are older than a day and not in your database:
def delete_unused_avatars do
cutoff = DateTime.add(DateTime.utc_now(), -1, :day)
# A `Fil.Ecto.Ref` field loads refs, so compare their paths.
stored =
from(u in User, where: not is_nil(u.avatar), select: u.avatar)
|> Repo.all()
|> MapSet.new(& &1.path)
{:ok, avatars} = Fil.ls(disk, "avatars", recursive: true)
avatars
|> Enum.filter(&(DateTime.before?(&1.stat.mtime, cutoff) and not MapSet.member?(stored, &1.path)))
|> Enum.each(&Fil.rm/1)
endWith {:array, Fil.Ecto.Ref}, each row is a list of refs, so flatten them before the set:
Repo.all(query) |> List.flatten() |> MapSet.new(& &1.path). The day between upload and deletion keeps files whose
form is still open. Fil.ls/3 lists every file under the prefix, so keep the job's prefix to the files of this upload,
and don't run it against a prefix that other code writes to.
On S3, a lifecycle rule that expires objects under a prefix does the same without a job, but it deletes every file
under the prefix, consumed or not. It fits when the uploads are temporary anyway, or when the app moves each consumed
file out of the prefix with Fil.rename/3 (on S3 a copy on the server).
Showing stored files
A signed URL lets the browser download a private file for a while, from S3 or through Fil.Plug (see
Signed URLs in the installation guide):
{:ok, avatar_url} = Fil.signed_url(user.avatar, expires_in: 3600)<img src={@avatar_url} alt="" />Files everyone may see can have a public URL instead, with Fil.url/2: from a public S3 bucket, or on local and memory
disks from Fil.Plugin.URL's base_url:, served by Fil.Plug with public: true.
Controller uploads
A controller gets a Plug.Upload with the path of a temporary file. Streaming it into Fil.write/4 is the whole
upload:
def create(conn, %{"document" => %Plug.Upload{} = upload}) do
id = Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false)
case Fil.write(disk, "documents/#{id}.pdf", {:file, upload.path}, if_exists: :error) do
{:ok, document} ->
json(conn, %{path: document.path})
{:error, error} ->
Logger.error("Document upload failed: " <> Exception.message(error))
conn |> put_status(500) |> json(%{error: "The upload failed."})
end
endFil.write/4 finds the size of the local file, so S3 sends the file in one request instead of uploading it in parts.
As with LiveView, upload.filename and upload.content_type come from the browser, so the path above is one the
server picked, and the content type comes from its extension through Fil.Plugin.ContentType.