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

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` — a `Coelho.Storage`, or an `{m, f, a}` returning one,
    required. The `{m, f, a}` form matters in an endpoint, where `init/1`
    may 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; see
    `Coelho.Attachments.signed_url/4`
  * `:metadata` — an `{m, f, a}` called with the key, returning
    `%{content_type: …, filename: …}` or `nil`. Coelho does not hold a
    repo, so this is how the row reaches the response
  * `:authorize` — an `{m, f, a}` or a `fun/2`, called with the
    connection and the key once the signature has checked out. Return
    `:ok` or `true` to 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
    end

The 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.

### What it does, exactly

Three things worth stating, because a security check is only worth what
its edges are worth:

  * **It runs before anything is fetched or minted.** The order is
    signature, then `:authorize`, then the bytes — so `:metadata` is not
    called for a request that will be refused, and
    `c:Coelho.Storage.redirect_url/3` is never asked for a presigned URL
    on behalf of a caller who is not allowed the file. A refusal costs
    the application one callback and no query.
  * **A refusal is `403`, with the same body a bad signature gets.** Not
    `404`: the two answers are deliberately identical, because telling
    "this is not yours" apart from "this does not exist" tells the caller
    it exists. A `404` only ever comes from past this gate, where the
    caller was already allowed the key.
  * **It fails closed, by failing.** Nothing here rescues: an exception
    out of the callback leaves the plug and becomes a `500`, and is never
    turned into permission. A callback that cannot reach its database
    stops the request rather than guessing at it — which is the only safe
    way round, and the reason to let it raise rather than answer `false`
    on an error it did not expect.

## 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 `c:Coelho.Storage.redirect_url/3` is expected to honour the
`:content_type` it is given.

---

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