Fil.LiveView (Fil v0.2.0)

Copy Markdown View Source

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
end

Each 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.AlreadyExistsError with reason: :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 a Fil.InvalidRequestError with reason: :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 the Phoenix.LiveView.UploadEntry and 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, because entry_ref/3 and the write call it separately, so build it from entry.uuid (as filename/1 does) and the socket's assigns, not from the time or a random value. entry.client_name, client_relative_path, client_type and client_size come from the browser and can be anything. For external/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 raises ArgumentError. The default value is &Fil.LiveView.filename/1.

  • :extensions (list of String.t/0) - The extensions the path may end with, such as [".jpg", ".png"]. They're compared with the path :path returned, in lowercase, before anything is written. A path with another extension, or without one, gives a Fil.InvalidRequestError with reason: :extension, and an empty list refuses every file. Without this option, the consume functions and external/2 take the extensions the upload's accept: allows (see Paths), and store_entry/4 checks none.

  • :if_exists - What to do if a file is already at the path, passed to Fil.write/4, or signed into the URL by external/2. :error writes nothing and gives a Fil.AlreadyExistsError, so an upload never replaces a file by accident. :overwrite replaces 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 that store_entry/4 takes from a direct upload. A larger one gives a Fil.InvalidRequestError with reason: :too_large and stays on the disk. The consume functions take the upload's max_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

cancel_upload(socket, params)

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.

upload_error(error, upload \\ nil)

@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 a Fil.InvalidRequestError with reason: :too_large or "EntityTooLarge". Without the upload, "The file is too large".

  • "This type of file isn't accepted": :not_accepted, a Fil.InvalidRequestError with reason: :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 from filename/1 never collide, so it comes from a :path of your own.

  • "The upload failed": :external_client_failure, {:writer_failure, reason}, Fil.NotFoundError, and a Fil.AlreadyExistsError with reason: :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]}

upload_field(assigns)

@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 to nil.

  • 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 or nil, so a {:array, Fil.Ecto.Ref} or Fil.Ecto.Ref field 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 of validate_length/3. They show once files are picked or the form was submitted (its action is neither nil nor :validate). The file input sends no param under the field's name, so Phoenix.Component.used_input?/1 alone would never show them.

    Defaults to nil.

  • label (:string) - The label of the file input. Defaults to nil.

  • 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, without translate, and false leaves it out.

    Defaults to nil.

  • errors (:list) - More errors to show below the files, such as the error of a failed consume_uploaded_entries/4. They get their text from upload_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", for cancel_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) - The phx-target of the buttons, such as @myself in a LiveComponent. Defaults to nil.

  • translate (:any) - A function that takes {msgid, bindings} and returns the text, such as the app's translate_error/1. Every text of the component's own goes through it, not label and hint. 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 to nil.

  • label_class (:any) - The classes of the label, instead of "label mb-1". Defaults to nil.

  • 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 to nil.

  • input_class (:any) - The classes of the file input, instead of "file-input w-full". Defaults to nil.

  • error_class (:any) - The classes of the file input while there are errors, added to input_class, instead of "file-input-error". Defaults to nil.

  • hint_class (:any) - The classes of the hint, instead of "label". Defaults to nil.

  • list_class (:any) - The classes of the list of files, instead of "mt-2 flex flex-col gap-2 text-sm". Defaults to nil.

  • 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 to nil.

  • progress_class (:any) - The classes of the progress bar, instead of "progress w-32". Defaults to nil.

  • button_class (:any) - The classes of the cancel and remove buttons, instead of "btn btn-sm btn-ghost". Defaults to nil.

  • message_class (:any) - The classes of the error messages, instead of "w-full text-sm text-error". Defaults to nil.

Slots

  • entry - The content of a picked file's row, with the Phoenix.LiveView.UploadEntry as 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 the Fil.Ref as its argument (:let={photo}). By default the name in its path. Paths from filename/1 are UUIDs, so an app with images shows a thumbnail from Fil.url/2 or Fil.signed_url/3 here, computed before rendering. It replaces the content, not the row: the remove button stays.

Types

target()

@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

consume_uploaded_entries(socket, name, target, opts \\ [])

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

consume_uploaded_entry(socket, entry, target, opts \\ [])

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.

entry_ref(target, entry, opts \\ [])

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

external(target, opts \\ [])

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 is 300.

  • :path (function of arity 1) - Gets the Phoenix.LiveView.UploadEntry and 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, because entry_ref/3 and the write call it separately, so build it from entry.uuid (as filename/1 does) and the socket's assigns, not from the time or a random value. entry.client_name, client_relative_path, client_type and client_size come from the browser and can be anything. For external/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 raises ArgumentError. The default value is &Fil.LiveView.filename/1.

  • :extensions (list of String.t/0) - The extensions the path may end with, such as [".jpg", ".png"]. They're compared with the path :path returned, in lowercase, before anything is written. A path with another extension, or without one, gives a Fil.InvalidRequestError with reason: :extension, and an empty list refuses every file. Without this option, the consume functions and external/2 take the extensions the upload's accept: allows (see Paths), and store_entry/4 checks none.

  • :if_exists - What to do if a file is already at the path, passed to Fil.write/4, or signed into the URL by external/2. :error writes nothing and gives a Fil.AlreadyExistsError, so an upload never replaces a file by accident. :overwrite replaces 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.

filename(arg)

@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"

store_entry(target, meta, entry, opts \\ [])

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.