Serves attachment bytes, behind a signature.
plug Coelho.Plug.Attachments,
at: "/attachments",
storage: Coelho.Storage.Disk.new("priv/uploads"),
secret: {MyApp.Uploads, :secret, []},
metadata: {MyApp.Uploads, :metadata, []}Requests that do not match :at fall through untouched.
Options
:at— the path prefix to serve from, required:storage— aCoelho.Storage, or an{m, f, a}returning one, required. The{m, f, a}form matters in an endpoint, whereinit/1may run at compile time and a storage built then would freeze the configuration it was built from:secret— the signing secret, as a binary or an{m, f, a}read at request time, required. It has to match what built the URL; seeCoelho.Attachments.signed_url/4:metadata— an{m, f, a}called with the key, returning%{content_type: …, filename: …}ornil. Coelho does not hold a repo, so this is how the row reaches the response:authorize— an{m, f, a}or afun/2, called with the connection and the key once the signature has checked out. Return:okortrueto serve, anything else to refuse. See below
A signature says the URL is genuine, not that it is yours
A signed URL is a bearer token: whoever holds it, holds the file. That is the right answer for a single-tenant application and the wrong one the moment there is more than one tenant, because the signature is checked against the secret and the secret is the application's. A URL minted for one organisation, forwarded or logged or pasted, is served to anyone who replays it.
Mounting this behind the application's authentication pipeline does not close it. That answers "may this person use the application", never "is this file theirs": a signed URL belonging to organisation A, replayed by a signed-in member of organisation B, passes the pipeline and the signature. Do mount it behind authentication — this needs a connection that already knows who is asking — but do not mistake it for the check.
:authorize is the check:
plug Coelho.Plug.Attachments,
at: "/attachments",
storage: {MyApp.Uploads, :storage, []},
secret: {MyApp.Uploads, :secret, []},
authorize: {MyApp.Uploads, :authorize, []}
def authorize(conn, key) do
case conn.assigns[:current_organisation] do
nil -> :error
organisation -> MyApp.Uploads.owned_by?(key, organisation)
end
endThe organisation comes from the connection, and never from the key. The
key arrives in the URL, which is to say from whoever sent the request, so
deriving the tenant from it is asking the attacker which tenant they are
in — see Coelho.Attachment.generate_key/1 on why a key prefix is an
inventory aid and not this.
Without :authorize, the signature is the only thing between a request
and the bytes. That is deliberate, and it is documented rather than
defaulted, because a default here would either break every single-tenant
application or quietly do nothing.
Serving other people's files
Uploads served from the application's own origin are a standing hazard:
a file the browser decides to render as HTML runs as the application.
So a response carrying bytes always has x-content-type-options: nosniff,
and only a short list of image types is served inline. Everything else —
including SVG, which is a document that can carry script — is sent as a
download, whatever it claims to be.
A redirect carries none of those headers, which is why one is only offered
for the types that would have been served inline anyway, and why a storage
implementing Coelho.Storage.redirect_url/3 is expected to honour the
:content_type it is given.