Live file uploads on a published page, from a declaration an author writes rather than Elixir.
The gap this closes
allow_upload/3 is a Phoenix.LiveView function and there was nowhere to call it: a compiled page
is a LiveView but the compiler emitted no mount/3, so @uploads never existed and the one
capability the UI kit already shipped and could not use — chelekom-file-field's live mode, which
renders <.live_file_input upload={@upload}> — had nothing to be given. A live component cannot
supply it either: allow_upload/3 is not a Phoenix.LiveComponent function.
So the page declares its uploads, and this attaches them.
# Page.uploads
[%{"name" => "attachment", "accept" => "image/*,.pdf", "max_entries" => 2}]
<.component component_name="chelekom-file-field" live upload={@uploads.attachment} />The ceilings are not the author's to raise
A published page is a form an anonymous visitor posts to, so the numbers a declaration carries are
read as requests and clamped: at most ten files of at most 20 MB each. An author who writes
max_file_size: 2_000_000_000 gets the ceiling, not an argument — and not a way to fill a disk
from a page anybody can open.
What a visitor may upload
A declaration must say what it takes: accept is required, and a declaration without a usable one
is not compiled. image/*, application/pdf and .csv are read against allowed_types/0, the
one list of extensions a visitor can send — images, PDF, plain text and CSV, office and OpenDocument
files, and zip. Everything else is dropped from the declaration, and HTML, SVG, XML and script
files can never be on it: the endpoint serves what is stored from the site's own origin, where such
a file would run with the session of whoever opens its link.
The type a browser reports is a claim, so it is not believed. A file is stored only when its first
bytes are one of the kinds the declaration takes — see MishkaCmsCore.Runtime.FileSignature — and
a file that is not, whatever it is called, is left unstored.
What a consumed file is named
Never what the visitor called it. The stored name is a random id plus an extension taken from the kind of the file's own first bytes, and the client's own name rides the descriptor as metadata. A filename is visitor input that becomes a path, which is the shape of every traversal bug ever written; not using it at all is shorter than escaping it correctly.
Files land under the page's own site, priv/static/uploads/<site_id>/forms/, which the endpoint
serves while that site is up, so the descriptor's url is a link that works and follows the site's
state. A page with no site stores nothing.
How a stored file is served
static_headers/0 names the headers a stored file is served with: a download, never sniffed into
another type, and sandboxed. MishkaCmsCore.Runtime.ShareCard.static_headers/1 is the Plug.Static
hook that adds them to every file under /uploads/<site_id>/forms/.
Who calls what
MishkaCmsWeb.RuntimeLive registers them at mount, because it is the LiveView — a compiled page
module supplies render/1 and handle_event/3 to it rather than being one of its own.
Where consumption happens
MishkaCmsCore.Runtime.PageForm.submit/5, before the values are validated — so a form rule can
require the field, and an author's own run function receives the descriptors with everything
else. Consuming is what completes an upload in LiveView; a page that never consumes leaves the
entry in the socket and the temporary file on disk.
Summary
Functions
Registers one allow_upload/3 per declaration the page module carries.
The extensions an accept leaves a visitor to upload, in the order of allowed_types/0.
The extensions, without a dot, a visitor may upload: the only ones an accept can name.
The most files and the largest file a declaration can ask for, whatever it says.
Consumes every completed entry on the socket and merges the descriptors into params.
What a page declared, as the compiler wrote it onto the module.
Where a site's consumed files are written: <site_id>/forms/ under priv/static/uploads/, which
the endpoint serves while that site is up.
The allow_upload/3 options one declaration asks for, clamped to what a public page may have.
The headers a file stored for a visitor is served with: saved rather than shown, never sniffed into another type, and sandboxed in case a browser shows it anyway.
The words of an accept that name nothing a visitor may upload: .svg, text/html, */*.
Functions
@spec allow(Phoenix.LiveView.Socket.t(), module() | nil) :: Phoenix.LiveView.Socket.t()
Registers one allow_upload/3 per declaration the page module carries.
Called by whatever mounts a compiled page — MishkaCmsWeb.RuntimeLive for a published one — after
the module is compiled and before the first render. Returns the socket untouched for a page that
declares nothing, which is every page that does not ask for uploads.
It is the MOUNTING LiveView that has to call this, and that is not a detail: a compiled page module
supplies render/1 and handle_event/3 to a host LiveView rather than being one, so a hook
attached to the page module would never run and @uploads would never exist. A generated mount/3
on it would be dead code for the same reason.
The extensions an accept leaves a visitor to upload, in the order of allowed_types/0.
accept is what a declaration writes — "image/*, .pdf", or a list of those words — and a word
is an extension (.pdf), a type (application/pdf) or a group of types (image/*). [] for an
accept that is missing, blank or names nothing a visitor may upload.
@spec allowed_types() :: [String.t()]
The extensions, without a dot, a visitor may upload: the only ones an accept can name.
@spec ceilings() :: %{entries: pos_integer(), file_size: pos_integer()}
The most files and the largest file a declaration can ask for, whatever it says.
@spec consume(Phoenix.LiveView.Socket.t(), map()) :: {Phoenix.LiveView.Socket.t(), map()}
Consumes every completed entry on the socket and merges the descriptors into params.
Answers {socket, params} untouched when the page registered no uploads, so every page that does
not use this pays one map lookup for it.
Each declared name gains a list of descriptors:
%{"url" => "/uploads/…", "name" => "invoice.pdf", "type" => "application/pdf", "size" => 8112}A list even for a single-entry upload, so a rule reading the field does not have to know which
kind it declared. type is the type of the file that was stored, read from its first bytes.
An entry whose first bytes are none of the kinds its declaration takes is consumed and not stored, so it has no descriptor.
What a page declared, as the compiler wrote it onto the module.
[] for anything that is not a compiled page — a preview, the canvas, a LiveView of the admin's
own — so the hook is inert wherever it does not apply.
Where a site's consumed files are written: <site_id>/forms/ under priv/static/uploads/, which
the endpoint serves while that site is up.
The allow_upload/3 options one declaration asks for, clamped to what a public page may have.
:accept is the declaration's accept read against allowed_types/0, as dotted extensions; []
when nothing in it can be uploaded, which allow/2 leaves unregistered.
Exposed because the compiler is not the only caller that has to agree with the ceilings — a test that asserts them reads them from here rather than restating the numbers.
The headers a file stored for a visitor is served with: saved rather than shown, never sniffed into another type, and sandboxed in case a browser shows it anyway.
The words of an accept that name nothing a visitor may upload: .svg, text/html, */*.