# `MishkaCmsCore.Runtime.PageUpload`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/page_upload.ex#L5)

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.

# `allow`

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

# `allowed_extensions`

```elixir
@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`

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

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

# `ceilings`

```elixir
@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`

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

# `declarations`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@spec static_headers() :: %{required(String.t()) =&gt; 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`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
