# `Coelho.Attachments`
[🔗](https://github.com/nseaSeb/coelho/blob/main/lib/coelho/attachments.ex#L1)

Attachments referenced by key, resolved to a URL at render time.

A stored attachment node carries an opaque key and the metadata worth
showing — filename, content type, size — but never a URL:

    %{
      "type" => "attachment",
      "attrs" => %{"key" => "01J8Z…", "filename" => "plan.pdf", "content_type" => "application/pdf"}
    }

The URL is produced when the document is rendered, from the `:context`
passed to `Coelho.Render`:

    Coelho.to_html(document, schema, context: %{resolve: &MyApp.Uploads.url/1})

What is stored is the key, never the URL, so every render asks again: a five
minute signed URL is fine, moving a bucket is a resolver change rather than
a data migration, and an attachment whose key no longer resolves degrades to
its filename instead of a broken image.

Resolving a stored reference late is not itself novel. What differs here is
that the reference is an ordinary attribute of a validated node, so the walk
that validates a document also enumerates what it points at — see `keys/2`.

## What Coelho does not do

It does not store bytes and is not a storage layer: where the file lives —
disk, object storage, anything else — and how a key becomes a URL are the
application's. `Coelho.Attachment` only records the metadata that the editor
and the renderer need.

# `context`

```elixir
@type context() :: (String.t() -&gt; String.t() | nil) | %{optional(any()) =&gt; any()}
```

What the renderer is given to turn a key into a URL: a function, a map of
key to URL for attachments already loaded, or a map carrying either under
`:resolve`.

# `keys`

```elixir
@spec keys(map(), Coelho.Schema.t()) :: [String.t()]
```

Every attachment key a document references, in document order.

A node counts as an attachment when its schema spec declares a `:key`
attribute, so a custom schema's own attachment-like nodes are found too.
Useful for preloading, and for working out which stored blobs a document
no longer refers to.

# `orphans`

```elixir
@spec orphans([String.t()], Enumerable.t(), Coelho.Schema.t()) :: [String.t()]
```

The keys that are stored and that no document mentions any more.

Deleting an image from a document leaves its bytes behind, so without
something calling this a Coelho store only ever grows.

    stored = Repo.all(from a in Coelho.Attachment, select: a.key)
    documents = Repo.all(from p in Post, select: p.body)

    Coelho.Attachments.orphans(stored, documents)

`stored_keys` is usually every `Coelho.Attachment` row: the table knows what
was uploaded, where a storage may not be able to enumerate itself cheaply.

`documents` is every document that could still be referring to something —
every row of every rich text column. **Passing fewer deletes files that are
still in use**, which is why this asks for the documents rather than going
and finding them: only the application knows where they all are, and a
half-answer here is data loss.

Deleting is left to the caller for the same reason. `sweep/4` does it when
the caller says so.

# `resolve`

```elixir
@spec resolve(context(), String.t() | nil) :: String.t() | nil
```

Resolves a key to a URL through the render context.

# `signed_url`

```elixir
@spec signed_url(String.t(), String.t(), binary(), keyword()) :: String.t()
```

A signed, expiring URL for an attachment.

This is what the render-time resolution is *for*: the signature covers the
key and an expiry, so a URL that leaks stops working, and none of it is
ever written into the document.

    Coelho.to_html(document, schema,
      context: %{resolve: &Coelho.Attachments.signed_url("/attachments", &1, secret)}
    )

The secret must be at least 32 bytes and must not be the application's
only secret if that one is also used elsewhere; derive it.

# `sweep`

```elixir
@spec sweep(Coelho.Storage.t(), [String.t()], Enumerable.t(), keyword()) ::
  {:ok, [String.t()]} | {:error, {String.t(), term()}, [String.t()]}
```

Deletes the stored keys no document mentions, returning what it removed.

Pass `dry_run: true` to be told what would go without anything going —
which is how this should be run the first time, against a real store.

    Coelho.Attachments.sweep(storage, keys, documents, dry_run: true)

A refusal stops the sweep and hands back both the key that refused and
everything already deleted, so the caller can still tidy the rows that now
point at nothing:

    {:error, {key, reason}, removed} = Coelho.Attachments.sweep(storage, keys, documents)

# `url`

```elixir
@spec url(context(), map()) :: String.t() | nil
```

The URL to render for an attachment node, or `nil`.

The resolver's answer goes through `Coelho.Render.safe_url/1`: a resolver
is application code, but it is often fed straight from stored metadata,
and the renderer is the last place a `javascript:` URL can be stopped.

# `verify`

```elixir
@spec verify(String.t(), %{optional(String.t()) =&gt; String.t()}, binary(), keyword()) ::
  :ok | {:error, :invalid | :expired}
```

Checks a signature produced by `signed_url/4`.

Takes the query parameters as a plug hands them over. Comparison is
constant time, and an expired signature is refused even though it is
valid.

---

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