Coelho.Attachments (coelho v0.2.0)

Copy Markdown View Source

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.

Summary

Types

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.

Functions

Every attachment key a document references, in document order.

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

Resolves a key to a URL through the render context.

A signed, expiring URL for an attachment.

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

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

Checks a signature produced by signed_url/4.

Types

context()

@type context() :: (String.t() -> String.t() | nil) | %{optional(any()) => 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.

Functions

keys(document, schema \\ Schema.default())

@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(stored_keys, documents, schema \\ Schema.default())

@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(fun, key)

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

Resolves a key to a URL through the render context.

signed_url(base, key, secret, opts \\ [])

@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(storage, stored_keys, documents, opts \\ [])

@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(context, node)

@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(key, params, secret, opts \\ [])

@spec verify(String.t(), %{optional(String.t()) => 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.