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
Functions
@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.
@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.
Resolves a key to a URL through the render context.
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.
@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)
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.
@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.