Stores Phoenix LiveView uploads on a disk.
Allow the upload as usual, then consume it with consume_uploaded_entries/4 instead of LiveView's own function:
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]} ->
{:noreply, save_avatar(socket, avatar.path)}
{:ok, []} ->
{:noreply, socket}
{:error, %Fil.InvalidRequestError{reason: :extension}} ->
{:noreply, put_flash(socket, :error, "Only .jpg and .png files can be uploaded.")}
{:error, error} ->
Logger.error("Avatar upload failed: " <> Exception.message(error))
{:noreply, put_flash(socket, :error, "The upload failed. Please try again.")}
end
endEach file is streamed from LiveView's temporary file into Fil.write/4, and the result is a list of Fil.Ref
structs in the order of the file input. The Phoenix guide walks through the whole form.
An error's message names the disk and the path in storage, so it's for logs. Show users a text of your own, chosen
by the error struct and its :reason.
The target is where the files go: a Fil.Ref for a directory, or a disk as Fil.Disk.resolve/1 takes it (a
disk, a 0-arity function or {module, function, args}). Paths are relative to the directory or the disk root.
Needs Phoenix LiveView 1.2 and Phoenix 1.8, optional dependencies of Fil.
Paths
:path decides where each file goes. The default is filename/1, the entry's UUID and its extension, such as
"0b2e8b8e-4a0a-4c1e-9d1e-2f6f4e0c7a11.png". The name the browser sent (entry.client_name) never becomes a path
unless you put it there, because it can be anything: ../../config.exs, a name another user already has, or
index.html.
A path that leaves the target gives a Fil.InvalidRequestError with reason: :ebadpath when the file is stored:
with a directory as the target, "../other.png" is refused, and with a disk, a path that leaves the disk root.
LiveView's accept: lets a file through when its type or its extension matches, and the type comes from the
browser. With accept: ~w(.png), a file named evil.html sent as image/png is accepted, and filename/1 would
name it <uuid>.html. So the consume functions check the extension of the final path before anything is written,
against the extensions accept: allows: the ones it lists, and those of its exact types (image/png allows
.png). :extensions replaces that list. A wildcard such as image/* allows no extension of its own, so with only
wildcards nothing is checked, and a mixed list such as ~w(.pdf image/*) refuses every image with
reason: :extension. List the extensions a wildcard stands for too, in accept: or in :extensions.
Every file is written with the content type of its path (MIME.from_path/1), never the type the browser sent. A
path with an unknown extension is stored as application/octet-stream, which takes precedence over the
:default of Fil.Plugin.ContentType.
All or nothing
consume_uploaded_entries/4 writes every file, then consumes the entries. If a write fails, it deletes the files
it wrote, keeps every entry in the upload, and returns the first error, so the user can submit the form again or
cancel an entry. With if_exists: :overwrite, deleting a written file also deletes the file it replaced. The same
can happen with the default if_exists: :error on S3-compatible servers that ignore the conditional write (see
Fil.write/4): a path: that collides with an existing file replaces it, and a later failure deletes it, which
paths with the entry's UUID, as filename/1 builds them, avoid.
Consuming talks to each entry's upload channel. If a channel is gone because the browser went away, the call exits as LiveView's own does, after deleting the files it wrote.
The files are written in the LiveView process, as with LiveView's own consume_uploaded_entries/3. A large file
blocks the LiveView until it's stored. Direct uploads don't.
Direct uploads
With external/2, the browser uploads each file straight to the disk, and the content never passes through the
LiveView:
allow_upload(socket, :avatar,
accept: ~w(.jpg .png),
max_file_size: 20_000_000,
external: Fil.LiveView.external(disk)
)It works on every disk that signs upload URLs: S3 with a presigned PUT, and a disk with Fil.Plugin.URL and a
:secret through Fil.Plug in your endpoint. Each URL is bound to the content type of the path, the size the
browser reported and :if_exists (see Uploads). With the default
if_exists: :error, a URL uploads one file once, and nobody who has it can replace the file after it was consumed.
With if_exists: :overwrite, the URL writes the file again on every request until it expires.
The browser side is the Fil uploader, which comes with Fil as colocated JS. A Phoenix 1.8 app passes it to its
LiveSocket in assets/js/app.js:
import {uploaders} from "phoenix-colocated/fil"
const liveSocket = new LiveSocket("/live", Socket, {hooks: {...colocatedHooks}, uploaders, params: {_csrf_token}})Consume the upload with the same disk as external/2. The consume functions don't write anything then: they check
the file at the path external/2 put into the entry's meta, which LiveView keeps on the server, with Fil.stat/2.
So :path, :extensions and :if_exists of the consume don't apply to direct uploads, and a different :path
doesn't move the file. A file that isn't there gives the Fil.NotFoundError of stat, and so does a directory
(with reason: :eisdir).
The browser only reports that it uploaded the file, and a modified client can report an upload it never made. So
:path has to give each entry a path of its own, as filename/1 does: with a path such as
&"docs/#{&1.client_name}", a user could claim a file someone else uploaded. The consume also refuses a file
that can't be this upload's:
- one older than the upload URL gives a
Fil.AlreadyExistsErrorwithreason: :before_upload. The check allows 30 seconds, because the clocks of the app and the storage can differ a little, and local disks and S3 keep the time in whole seconds - one larger than the upload's
max_file_size(the URL takes only the size the browser reported) gives aFil.InvalidRequestErrorwithreason: :too_large
A failed consume never deletes the file of a direct upload, because it may be someone else's, and the form can be submitted again.
A presigned PUT goes to S3 directly, so none of the disk's plugins run: no encryption or compression, and no
Fil.Plugin.Thumbnails. Uploads through Fil.Plug are written with Fil.write/4, where plugins run. For
thumbnails on S3, attach Fil.Plugin.Thumbnails with mode: :manual and call
Fil.Plugin.Thumbnails.generate/1 with each ref the consume returns.
A file whose form is never submitted stays on the disk. The Phoenix guide shows how to clean those up, and the CORS rule an S3 bucket needs.
Upload field
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. It's
styled with daisyUI like the components Phoenix 1.8 generates, and each part takes classes
of your own. cancel_upload/2 handles its cancel buttons, and upload_error/2 gives the texts of its errors. The
Phoenix guide shows a complete form.
It works with external/2 as it is: the uploader reports the progress, and a direct upload that's refused or fails
shows as its entry's error ({:external_metadata_failure, %{reason: :extension}} or :external_client_failure).
Without auto_upload: true, LiveView asks for all upload URLs when the form is submitted and stops at the first
entry external/2 refuses, so only that entry shows an error until the form is submitted again.
Building your own integration
Code that calls LiveView's Phoenix.LiveView.consume_uploaded_entries/3 itself, to do more in the same callback,
stores each entry with store_entry/4:
Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn meta, entry ->
case Fil.LiveView.store_entry(disk, meta, entry) do
{:ok, avatar} -> {:ok, avatar}
{:error, error} -> {:postpone, error}
end
end)entry_ref/3 returns the ref an entry will be written to, without writing anything, for a form that saves the
path before it consumes the upload. Both take the same options as the consume functions.
Testing
Uploads in Phoenix.LiveViewTest to a memory disk need only Fil.Adapter.Memory.checkout/0 in the test, no
Fil.Adapter.Memory.allow/2: the LiveView process finds the test's store through $callers.
Options
:path(function of arity 1) - Gets thePhoenix.LiveView.UploadEntryand returns the path of the file, relative to the target. The path may contain directories. It has to be the same each time for the same entry, becauseentry_ref/3and the write call it separately, so build it fromentry.uuid(asfilename/1does) and the socket's assigns, not from the time or a random value.entry.client_name,client_relative_path,client_typeandclient_sizecome from the browser and can be anything. Forexternal/2, it also has to be a path no other entry gets, because the consume takes the file there for the upload (see Direct uploads). A result that isn't a string raisesArgumentError. The default value is&Fil.LiveView.filename/1.:extensions(list ofString.t/0) - The extensions the path may end with, such as[".jpg", ".png"]. They're compared with the path:pathreturned, in lowercase, before anything is written. A path with another extension, or without one, gives aFil.InvalidRequestErrorwithreason: :extension, and an empty list refuses every file. Without this option, the consume functions andexternal/2take the extensions the upload'saccept:allows (see Paths), andstore_entry/4checks none.:if_exists- What to do if a file is already at the path, passed toFil.write/4, or signed into the URL byexternal/2.:errorwrites nothing and gives aFil.AlreadyExistsError, so an upload never replaces a file by accident.:overwritereplaces it, and a URL signed with it can write the file again and again until it expires, also after the upload was consumed. The consume functions ignore it for direct uploads. The default value is:error.:max_file_size(pos_integer/0) - The largest file in bytes thatstore_entry/4takes from a direct upload. A larger one gives aFil.InvalidRequestErrorwithreason: :too_largeand stays on the disk. The consume functions take the upload'smax_file_size.
Summary
Upload field
Cancels the entry of a cancel button of upload_field/1, from the params of its event
Returns the text of an upload error as {msgid, bindings}, the shape of Ecto's errors, for the app's
translate_error/1.
Renders the upload part of a form: a drop zone with the file input, a row for each picked file and a row for each file the record has, and the errors of the upload and the field.
Types
Where the files go: a ref for a directory, or a disk as Fil.Disk.resolve/1 takes it.
Functions
Writes the uploaded files of the upload name to target and consumes their entries.
Writes one uploaded entry to target and consumes it.
Returns the ref entry will be written to in target, without writing or checking anything, like Fil.ref/2.
Returns the function for external: in Phoenix.LiveView.allow_upload/3, which lets the browser upload each file
straight to target, on every disk.
Returns the default path of an entry: its UUID and the extension of the name the browser sent, in lowercase.
Writes one entry to target, without consuming it. meta is what LiveView passes to the function of its
Phoenix.LiveView.consume_uploaded_entries/3, with the path of the temporary file.
Upload field
@spec cancel_upload(Phoenix.LiveView.Socket.t(), map()) :: Phoenix.LiveView.Socket.t()
Cancels the entry of a cancel button of upload_field/1, from the params of its event:
def handle_event("cancel-upload", params, socket), do: {:noreply, Fil.LiveView.cancel_upload(socket, params)}Finds the upload named in "upload" and calls Phoenix.LiveView.cancel_upload/3 with the entry in "ref". An
upload or entry that isn't there, after a double click or from an event the page didn't send, leaves the socket as
it is, where LiveView's function raises.
@spec upload_error(term(), Phoenix.LiveView.UploadConfig.t() | nil) :: {String.t(), keyword()}
Returns the text of an upload error as {msgid, bindings}, the shape of Ecto's errors, for the app's
translate_error/1.
Takes the errors of Phoenix.Component.upload_errors/1 and Phoenix.Component.upload_errors/2, Fil's error
structs, and {msgid, bindings} tuples, which it returns as they are. With the upload, the texts of :too_large
and :too_many_files name its limits. upload_field/1 shows its errors with it, and it gives a flash its text:
{:error, error} -> {:noreply, put_flash(socket, :error, translate_error(Fil.LiveView.upload_error(error)))}"The file is larger than %{max}":
:too_large, and aFil.InvalidRequestErrorwithreason: :too_largeor"EntityTooLarge". Without the upload, "The file is too large"."This type of file isn't accepted":
:not_accepted, aFil.InvalidRequestErrorwithreason: :extension, and{:external_metadata_failure, %{reason: :extension}}."You can upload at most %{count} file(s)":
:too_many_files. Without the upload, "Too many files"."A file with this name already exists": any other
Fil.AlreadyExistsError. Paths fromfilename/1never collide, so it comes from a:pathof your own."The upload failed":
:external_client_failure,{:writer_failure, reason},Fil.NotFoundError, and aFil.AlreadyExistsErrorwithreason: :before_upload(a direct upload whose file is older than its URL)."The upload couldn't be started": any other
{:external_metadata_failure, meta}."The file couldn't be uploaded": anything else, such as the error of a
validate:function.iex> Fil.LiveView.upload_error(:not_accepted) {"This type of file isn't accepted", []} iex> upload = %Phoenix.LiveView.UploadConfig{max_entries: 3, max_file_size: 5_000_000} iex> Fil.LiveView.upload_error(:too_large, upload) {"The file is larger than %{max}", [max: "5 MB", max_file_size: 5_000_000]} iex> Fil.LiveView.upload_error(:too_many_files, upload) {"You can upload at most %{count} file(s)", [count: 3]} iex> Fil.LiveView.upload_error(%Fil.AlreadyExistsError{}) {"A file with this name already exists", []} iex> Fil.LiveView.upload_error(%Fil.AlreadyExistsError{reason: :before_upload}) {"The upload failed", []} iex> Fil.LiveView.upload_error({"is invalid", [validation: :custom]}) {"is invalid", [validation: :custom]}
@spec upload_field(map()) :: Phoenix.LiveView.Rendered.t()
Renders the upload part of a form: a drop zone with the file input, a row for each picked file and a row for each file the record has, and the errors of the upload and the field.
<.form for={@form} id="product-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} label="Name" />
<Fil.LiveView.upload_field
upload={@uploads.photos}
existing={@photos}
field={@form[:photos]}
label="Photos"
/>
<.button phx-disable-with="Saving...">Save</.button>
</.form>The LiveView allows the upload in mount/3 and stores the files with consume_uploaded_entries/4 when the form is
saved, as without the component. The form needs phx-change, as every LiveView upload does. The buttons send their
events to the LiveView (or to target), with the upload's name in "upload":
def handle_event("cancel-upload", params, socket), do: {:noreply, Fil.LiveView.cancel_upload(socket, params)}
def handle_event("remove-file", %{"upload" => "photos", "path" => path}, socket) do
{:noreply, update(socket, :photos, fn photos -> Enum.reject(photos, &(&1.path == path)) end)}
end
def handle_event("remove-file", %{"upload" => "avatar"}, socket), do: {:noreply, assign(socket, :avatar, nil)}The last clause is for a single file (a Fil.Ecto.Ref field) in another upload field on the same page.
Removing deletes nothing. The LiveView keeps the files the record keeps in an assign, passed as existing, and
saving puts those and the new ones into the changeset. Fil.Ecto.Ref.removed/2 returns the ones it drops, to delete
after the commit. The Phoenix guide shows the complete form, with the save.
With auto_upload: true and entries consumed as they finish, a consumed entry leaves the list, so append its ref
to existing.
import Fil.LiveView, only: [upload_field: 1] in the html_helpers of the app's web module gives
<.upload_field>.
Styling
The default classes are daisyUI 5 classes, as in the core_components.ex Phoenix 1.8
generates. A class attribute replaces the default of its part, as class does on the generated components, so to
add a class, copy the default and append to it. A few elements take no class attribute: the <div> around the
content of each row, the preview, the name and the size in a row, and the <div> around the error messages. Their
classes are Tailwind utilities only. The slots replace the content of a row, not the row, so the buttons, the
progress bar and the errors keep their classes.
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.
Tailwind builds only the classes it finds in its sources, so the app's assets/css/app.css needs a line for Fil:
@source "../../deps/fil/lib/fil/live_view";Texts
Every text of the component's own goes through translate as {msgid, bindings}, errors included (see
upload_error/2). The label and a hint you pass are shown as they are. With Gettext, pass the app's
translate_error/1, which looks the texts up in the errors domain. mix gettext.extract doesn't find texts built
in a dependency, so add these to priv/gettext/errors.pot:
msgid "or drop files here"
msgid "Remove"
msgid "Remove %{name}"
msgid "Cancel"
msgid "Cancel upload of %{name}"
msgid "Upload progress of %{name}"
msgid "The file is larger than %{max}"
msgid "The file is too large"
msgid "This type of file isn't accepted"
msgid "You can upload at most %{count} file(s)"
msgid "Too many files"
msgid "A file with this name already exists"
msgid "The upload failed"
msgid "The upload couldn't be started"
msgid "The file couldn't be uploaded"Each gets an empty msgstr. The one with %{count} also gets a msgid_plural and a msgstr per plural form, like
Ecto's messages with a count in that file. %{name} is the file's name, %{max} the size limit as text such as
"5 MB", and the bindings of that message also have max_file_size in bytes.
Attributes
id(:string) - The id of the root<div>. Defaults tonil.upload(Phoenix.LiveView.UploadConfig) (required) - The upload, such as@uploads.photos.existing(:any) - The files the record has, each with a remove button: a list of refs, a single ref ornil, so a{:array, Fil.Ecto.Ref}orFil.Ecto.Reffield goes in as it is. A single ref is hidden while a file is picked, because saving replaces it, and cancelling that file shows it again. A list stays.Defaults to
[].field(Phoenix.HTML.FormField) - The form field of the files, for its errors, such as those ofvalidate_length/3. They show once files are picked or the form was submitted (its action is neithernilnor:validate). The file input sends no param under the field's name, soPhoenix.Component.used_input?/1alone would never show them.Defaults to
nil.label(:string) - The label of the file input. Defaults tonil.hint(:any) - The text below the file input, such as"PNG or JPG, at most 5 MB", instead of "or drop files here". It's shown as it is, withouttranslate, andfalseleaves it out.Defaults to
nil.errors(:list) - More errors to show below the files, such as the error of a failedconsume_uploaded_entries/4. They get their text fromupload_error/2, except strings, which are shown as they are.Defaults to
[].required(:boolean) - Passed to the file input while there are no existing files, so an edit form with a stored file can be saved without picking a new one.Defaults to
false.cancel(:string) - The event of the cancel buttons, with the params"upload"and"ref", forcancel_upload/2. Defaults to"cancel-upload".remove(:string) - The event of the remove buttons, with the params"upload"and"path", the path of the ref. Defaults to"remove-file".target(:any) - Thephx-targetof the buttons, such as@myselfin a LiveComponent. Defaults tonil.translate(:any) - A function that takes{msgid, bindings}and returns the text, such as the app'stranslate_error/1. Every text of the component's own goes through it, notlabelandhint. Without it, the bindings are filled in and nothing is translated.Defaults to
nil.class(:any) - The classes of the root<div>, instead of"fieldset mb-2". Defaults tonil.label_class(:any) - The classes of the label, instead of"label mb-1". Defaults tonil.dropzone_class(:any) - The classes of the drop zone, a<label>around the file input, instead of"flex flex-col gap-2 rounded-box border-2 border-dashed border-base-300 p-4 [&.phx-drop-target-active]:border-primary". Defaults tonil.input_class(:any) - The classes of the file input, instead of"file-input w-full". Defaults tonil.error_class(:any) - The classes of the file input while there are errors, added toinput_class, instead of"file-input-error". Defaults tonil.hint_class(:any) - The classes of the hint, instead of"label". Defaults tonil.list_class(:any) - The classes of the list of files, instead of"mt-2 flex flex-col gap-2 text-sm". Defaults tonil.entry_class(:any) - The classes of the row of each file, instead of"flex flex-wrap items-center gap-x-3 gap-y-1". Defaults tonil.progress_class(:any) - The classes of the progress bar, instead of"progress w-32". Defaults tonil.button_class(:any) - The classes of the cancel and remove buttons, instead of"btn btn-sm btn-ghost". Defaults tonil.message_class(:any) - The classes of the error messages, instead of"w-full text-sm text-error". Defaults tonil.
Slots
entry- The content of a picked file's row, with thePhoenix.LiveView.UploadEntryas its argument (:let={upload_entry}). By default an image preview (for image types a browser shows), the name and the size. It replaces the content, not the row: the progress bar, the cancel button and the errors stay.file- The content of a stored file's row, with theFil.Refas its argument (:let={photo}). By default the name in its path. Paths fromfilename/1are UUIDs, so an app with images shows a thumbnail fromFil.url/2orFil.signed_url/3here, computed before rendering. It replaces the content, not the row: the remove button stays.
Types
@type target() :: Fil.Ref.t() | Fil.Disk.source()
Where the files go: a ref for a directory, or a disk as Fil.Disk.resolve/1 takes it.
Functions
@spec consume_uploaded_entries( Phoenix.LiveView.Socket.t(), atom() | String.t(), target(), keyword() ) :: Fil.result([Fil.Ref.t()])
Writes the uploaded files of the upload name to target and consumes their entries.
Returns {:ok, refs} in the order of the file input, {:ok, []} when nothing was uploaded, or the first
{:error, exception}, with nothing written and every entry kept (see All or nothing).
Raises ArgumentError, as LiveView does, when entries are still in progress or name isn't an allowed upload.
@spec consume_uploaded_entry( Phoenix.LiveView.Socket.t(), Phoenix.LiveView.UploadEntry.t(), target(), keyword() ) :: Fil.result(Fil.Ref.t())
Writes one uploaded entry to target and consumes it.
For uploads with auto_upload: true and a progress: callback, which consume each entry when it's done:
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} ->
Logger.error("Avatar upload failed: " <> Exception.message(error))
{:noreply, put_flash(socket, :error, "The upload failed. Please try again.")}
end
end
defp handle_progress(:avatar, _entry, socket), do: {:noreply, socket}Returns {:ok, ref}, or {:error, exception} with nothing written and the entry kept in the upload. Raises
ArgumentError when the entry is still in progress.
@spec entry_ref(target(), Phoenix.LiveView.UploadEntry.t(), keyword()) :: Fil.Ref.t()
Returns the ref entry will be written to in target, without writing or checking anything, like Fil.ref/2.
Takes the same options as store_entry/4, and uses only :path. A path the write refuses, for its extension or
because it leaves the target, gives the error when the entry is stored.
@spec external(target(), keyword()) :: (Phoenix.LiveView.UploadEntry.t(), Phoenix.LiveView.Socket.t() -> {:ok | :error, map(), Phoenix.LiveView.Socket.t()})
Returns the function for external: in Phoenix.LiveView.allow_upload/3, which lets the browser upload each file
straight to target, on every disk.
allow_upload(socket, :avatar,
accept: ~w(.jpg .png),
max_file_size: 20_000_000,
external: Fil.LiveView.external(disk)
)For each file, the function builds its path with :path, checks its extension, and signs an upload URL for it
with Fil.signed_url/3, bound to the content type of the path, the size the browser reported and :if_exists.
The browser side is the Fil uploader (see Direct uploads). Consume the upload with the
same disk as usual; the consume functions check the file at the signed path instead of writing it, and ignore their
own :path, :extensions and :if_exists.
:path has to give each entry a path no other entry gets, as the default filename/1 does, because the consume
takes the file at the path for the upload.
A path the extension check refuses gives the entry the error {:external_metadata_failure, %{reason: :extension}}
in Phoenix.Component.upload_errors/2. Any other error, such as a disk that can't sign URLs, gives
{:external_metadata_failure, %{reason: :error}} and is logged with Logger.error/1. LiveView sends the error to
the browser too, so it holds no message, which would name the disk and the path.
Options
:expires_in- How long the upload URL stays valid, in seconds. LiveView asks for it right before the upload starts, and the storage checks it when the request starts, so a large file that takes longer still arrives. The default value is300.:path(function of arity 1) - Gets thePhoenix.LiveView.UploadEntryand returns the path of the file, relative to the target. The path may contain directories. It has to be the same each time for the same entry, becauseentry_ref/3and the write call it separately, so build it fromentry.uuid(asfilename/1does) and the socket's assigns, not from the time or a random value.entry.client_name,client_relative_path,client_typeandclient_sizecome from the browser and can be anything. Forexternal/2, it also has to be a path no other entry gets, because the consume takes the file there for the upload (see Direct uploads). A result that isn't a string raisesArgumentError. The default value is&Fil.LiveView.filename/1.:extensions(list ofString.t/0) - The extensions the path may end with, such as[".jpg", ".png"]. They're compared with the path:pathreturned, in lowercase, before anything is written. A path with another extension, or without one, gives aFil.InvalidRequestErrorwithreason: :extension, and an empty list refuses every file. Without this option, the consume functions andexternal/2take the extensions the upload'saccept:allows (see Paths), andstore_entry/4checks none.:if_exists- What to do if a file is already at the path, passed toFil.write/4, or signed into the URL byexternal/2.:errorwrites nothing and gives aFil.AlreadyExistsError, so an upload never replaces a file by accident.:overwritereplaces it, and a URL signed with it can write the file again and again until it expires, also after the upload was consumed. The consume functions ignore it for direct uploads. The default value is:error.
@spec filename(Phoenix.LiveView.UploadEntry.t()) :: String.t()
Returns the default path of an entry: its UUID and the extension of the name the browser sent, in lowercase.
An extension that isn't 1 to 16 ASCII letters and digits is left out, so the name the browser sent never adds anything else to the path.
iex> entry = %Phoenix.LiveView.UploadEntry{uuid: "0b2e8b8e", client_name: "Holiday.PNG"}
iex> Fil.LiveView.filename(entry)
"0b2e8b8e.png"
iex> Fil.LiveView.filename(%{entry | client_name: "x.ph/p"})
"0b2e8b8e"
iex> Fil.LiveView.filename(%{entry | client_name: "noext"})
"0b2e8b8e"
@spec store_entry(target(), map(), Phoenix.LiveView.UploadEntry.t(), keyword()) :: Fil.result(Fil.Ref.t())
Writes one entry to target, without consuming it. meta is what LiveView passes to the function of its
Phoenix.LiveView.consume_uploaded_entries/3, with the path of the temporary file.
Returns {:ok, ref} or {:error, exception}. Checks the extension only against an explicit :extensions,
because there's no socket to find the upload's accept: in. See
Building your own integration.
With the meta of external/2, the file is on the disk already, so it's checked instead of written, as the
consume functions do (see Direct uploads), with :max_file_size as the limit.
Raises ArgumentError for any other meta without a :path: the meta of another external uploader, or of a
custom writer:. Neither leaves a temporary file to store.