# Images and files

> Status: built. The decision is [adr/0015](https://codna.me/docs/adr/0015-page-owned-files.md).

A page can hold images and PDFs. Each one belongs to that page and to nothing else: whoever can read
the page can open its files, whoever can edit the page can add to it, and sharing the page shares
them. There is no library of files apart from the pages they are in.

## Where a page's files are

A page keeps its files in a folder beside it named after its slug. The page at
`wiki:engineering/onboarding` keeps them in `onboarding.files/`, and refers to them with ordinary
relative links, written as they would be for a file on disk:

```markdown
![The deploy pipeline](onboarding.files/diagram.png)

The signed [contract](onboarding.files/vendor-contract.pdf) is attached.
```

The page stays plain markdown ([adr/0006](https://codna.me/docs/adr/0006-inline-markdown-links.md)). In an export the
folder is a real one, so the images show when the tree is opened in Obsidian or browsed on GitHub:

```
wiki/engineering/onboarding.md
wiki/engineering/onboarding.files/diagram.png
wiki/engineering/onboarding.files/vendor-contract.pdf
wiki/engineering/onboarding/          ← the pages under it, if it is a folder page
```

A slug never contains a dot, so `onboarding.files` is never the name of a page or of a folder.

Another page can show the same image with a relative path to it, such as
`![](../engineering/onboarding.files/diagram.png)`. It shows only to people who can read
`onboarding`: the file has its own page's permission, wherever it is shown. Put an image in the page
that shows it.

An image written as a web address, `![](https://example.com/chart.png)`, or as a `data:` URL shows
too. It is not a page's file: it is fetched from where it lives each time the page is read, so its
host learns that someone opened the page, from which IP address and when. The web UI's
Content-Security-Policy allows images from any `https:` address for this, and refuses plain `http:`
ones (SECURITY.md in the repository explains the policy).

## Adding an image while writing

In the editor, an image or a PDF is added by pasting it, by dropping it on the text, or with
**Image** in the slash menu (`/image`), which asks for files. Each is uploaded to the page, and the
markdown that shows it is written where it was put, on a line of its own:

- an image as `![caption](onboarding.files/name.png)`, captioned with its file's name in words
  (`deploy-pipeline.png` is "deploy pipeline"), or with no caption when the name is one a device
  made up (`image.png`, `Screenshot 2026-…`, `IMG_4031.jpeg`);
- a PDF as a link, `[Vendor contract](onboarding.files/vendor-contract.pdf)`.

While a file uploads, a placeholder holds its place and nothing is written into the text, so saving
in the middle saves no half-made link. A paste that carries text as well as a picture is text: a
spreadsheet's cells arrive with a picture of them, and the cells are what was meant.

A line that is only images is drawn as the images, as reading draws them. Its caption — the alt
text, which says what the picture shows to someone who cannot see it, and which search finds —
shows over the picture on hover. Choosing it selects the caption in the markdown, to be written
over; a picture with none says **Add a description**. Clicking the picture puts the cursor on its
line, and the markdown shows above it.

A page that is being written for the first time has no files until it is saved, since a file
belongs to a page that exists. Pasting an image into it says so.

In reading, a page's images are shown from where they are served, and load as they come into view.
An image whose path names no page's file — markdown pasted in from somewhere else, naming a file
that never came with it — shows its caption in a dashed box instead of a broken picture.

## Diagrams

A diagram is not a file. It is written as text, in a fenced code block marked `mermaid`, and drawn
as a picture wherever the page is read, as GitHub draws it
([adr/0019](https://codna.me/docs/adr/0019-diagrams-are-mermaid-text.md)):

````markdown
```mermaid
flowchart LR
  Browser --> Core
  Core --> Postgres[(Postgres)]
```
````

The page keeps the text, so a diagram is read, changed and reviewed like the rest of the page: by a
person or an agent, with each change in the page's history and the block as written in an export.
[Mermaid](https://mermaid.js.org/intro/syntax-reference.html) draws flowcharts, sequence, class,
state and entity-relationship diagrams, Gantt and pie charts, and more.

- **Reading.** A diagram is drawn in CoDNA's colours, light or dark as the page is, and again when
  the theme changes. One wider than the text is shrunk to fit it, so the page shows it whole and
  never scrolls sideways. Click it, or its expand button, to open it over the page as large as the
  window allows: zoom with the buttons, `+` `-` and `0`, or a pinch, and drag it to move around.
  One that Mermaid cannot read shows its text, with a line under it saying what was wrong; the rest
  of the page reads as ever.
- **Writing.** In the editor a diagram is drawn until the cursor goes into it; a click on it puts the
  cursor there, and its expand button opens it full size. With the cursor in it, its text shows,
  with the drawing under it, drawn again a moment after each change. While the text does not parse,
  the last drawing stays, with what is wrong under it.
- **Elsewhere.** An excerpt, a search result or a list row says `[Diagram]` where a diagram is, never
  its code. The words inside a diagram are not searched, as code is not, so say what matters in the
  text around it too.
- **Safety.** Mermaid draws with its strict security level and no HTML labels: no click handlers and
  no markup in labels, and an `%%{init}%%` line in a diagram cannot change that. A diagram is a
  picture, not a menu: a box a `click` line links is drawn, but leads nowhere. What Mermaid draws is
  sanitised again before it reaches the page, and its stylesheet keeps only rules about the diagram
  itself. Mermaid is loaded only by a page that has a diagram.

## What a page holds

| | |
|---|---|
| Types | PNG, JPEG, GIF and WebP images; PDFs |
| Size | 20 MB each |
| SVG | Refused: it can carry script. Export it as PNG. |
| Changes | None. A file never changes after it is uploaded; a corrected diagram is a new file. |

The type is decided by the bytes, not by the file's name or what the browser said it was. A file
called `cat.png` that is really a web page is refused as one.

A file is named after what was uploaded, slugified as a page's title is, with the extension of what
the bytes are: `Screenshot 2026-10-15 at 18.30.png` becomes `screenshot-2026-10-15-at-18-30.png`. A
name the page already uses gets `-2`, `-3`. The same bytes uploaded to the same page again are the
file already there.

## Over HTTP

- **`POST /api/documents/{id}/files`** adds one file, sent as the multipart field `file`. It needs
  edit access to the page.
- **`GET /api/documents/{id}/files`** lists the page's files. It needs read access.
- **`GET /api/files/<space>/<path>/<slug>.files/<name>`** is the file itself, at its path in an
  export. It needs read access to its page.

An upload answers with what to write in the page:

```json
{
  "name": "diagram.png",
  "href": "onboarding.files/diagram.png",
  "path": "wiki/engineering/onboarding.files/diagram.png",
  "url": "/api/files/wiki/engineering/onboarding.files/diagram.png",
  "mime_type": "image/png", "size": 48213, "image": true,
  "created_at": "2026-10-15T18:30:12Z"
}
```

All three routes are in the `content` class ([permissions.md](https://codna.me/docs/permissions.md#route-classes)):
people only, never a management token. A file that someone cannot reach is a 404 whether the page
is missing, is not theirs to read, or does not hold that file, so a file never confirms that a page
exists.

A file is served with the type found at upload and headers that keep a hostile one harmless:

- `X-Content-Type-Options: nosniff`: the browser takes the stored type and does not guess another.
- `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`: an image
  opened on its own still shows, and nothing in it runs.
- `Content-Disposition`: `inline` for images, `attachment` for PDFs. Browsers' PDF viewers do not
  run under a sandbox, so a PDF downloads.
- `Cache-Control: private, no-cache` and an `ETag`: the browser keeps the file but asks again each
  time, so taking a share away holds from the next view. The answer is a 304 when nothing changed.
- `Cross-Origin-Resource-Policy: same-origin`: no other site can embed it.

## When a page moves

Moving a page rewrites its own relative links in the move's revision
([linking.md](https://codna.me/docs/linking.md#the-moved-documents-own-links)), and links to files are among them:

- Its own files follow it. A new slug turns `onboarding.files/x.png` into `setup.files/x.png`. A
  move to another folder under the same slug changes nothing, since the files move with the page.
- A link to another page's file is rebased to reach it from the new folder, and pointed at where
  that page is now if it has moved too.

Other pages that show a moved page's file are not edited. Their links keep working through the
address the page left, until another page takes that address. File links are not part of the link
graph, so a moved file raises no decision in anyone's inbox.

## History, the trash and retention

Every revision is kept, and restoring an old one should still show its images, so **a file is kept
for as long as its page exists.** Taking an image out of a page takes out the link, not the file.

- A page in the trash takes its files with it: nobody can read a trashed page, so nobody can open
  its files, and restoring the page brings them back.
- When a trashed page is purged after its retention window, its files' bytes are deleted.
- A file that no revision of its page has ever named is deleted thirty days after it was uploaded.
  This is the image pasted into an edit that was then abandoned. Once any revision names a file, it
  stays until the page is purged.

The bytes are in the storage driver's root (`CODNA_STORAGE_PATH`), under `files/YYYY/MM/<uuid>.<ext>`
beside the `raw/` originals, so whatever backs that directory up has both. The name a file has in
its page is in the `attachments` table, never in the path.

## Search

A page is found by its images' alt text and by its files' names, split into words: a page holding
`vendor-contract-2026.pdf` is found by "vendor contract". A file's bytes are never read for search.
Alt text is left out of excerpts, so a page that opens with a screenshot does not have its preview
open with the screenshot's caption.

## Export and import

An export writes each page's files into its `<slug>.files/` folder, beside the page, and lists them
in the manifest ([export.md](https://codna.me/docs/export.md)). A person's export holds the files of the pages they can
read, and nothing else. `codna import` reads the folder back into the page: a file the page does not
hold is added, one it holds with the same bytes is left alone, and one it holds with other bytes is
reported and skipped.

An import from another tool (`/import`: Notion, Confluence, a folder of markdown) brings the images
its pages show as their files in the same way, each written with the version of the page that names
it ([ingestion.md](https://codna.me/docs/ingestion.md#images-and-attachments)).

## Agents

An agent reads a page's images through MCP with `get_file` ([reference](https://codna.me/docs/reference/tools.md)):
it names the page and the image, by its link as the page writes it (`onboarding.files/error.png`) or
by its name, and gets the image back as image content, so the model sees the screenshot. An image
larger than 1568 pixels on a side, or than 3.75 MB, is scaled to fit first: a model sees no more
than that, and clients refuse larger payloads. A WebP that would need scaling is refused, since
CoDNA does not decode WebP. A PDF is described, not sent. `get_document` lists a page's files in its
header.

The agent reaches a file through its page, so it sees what its person can read and nothing more.
Agents cannot add files yet. A diagram needs none: an agent writes it as a `mermaid` block
([above](#diagrams)), and the MCP server's instructions say so.
