MishkaCmsCore.Runtime.PageUpload (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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

allow(socket, page_module)

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.

allowed_extensions(accept)

@spec allowed_extensions(term()) :: [String.t()]

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.

allowed_types()

@spec allowed_types() :: [String.t()]

The extensions, without a dot, a visitor may upload: the only ones an accept can name.

ceilings()

@spec ceilings() :: %{entries: pos_integer(), file_size: pos_integer()}

The most files and the largest file a declaration can ask for, whatever it says.

consume(socket, params)

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.

declarations(module)

@spec declarations(module() | nil) :: [map()]

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.

directory(site_id)

@spec directory(String.t()) :: String.t()

Where a site's consumed files are written: <site_id>/forms/ under priv/static/uploads/, which the endpoint serves while that site is up.

options(declaration)

@spec options(map()) :: keyword()

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.

static_headers()

@spec static_headers() :: %{required(String.t()) => String.t()}

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.

unsupported(accept)

@spec unsupported(term()) :: [String.t()]

The words of an accept that name nothing a visitor may upload: .svg, text/html, */*.