---
doc:
  title: Files and pictures
guide:
  slug: files
  brief: the app's files, its icon, uploads, pictures
  description: >-
    app_files for the app's own files — what a write returns, the patch and
    fetch ops, the history every write keeps and the restore that puts one
    back, the icon.png that gives an app an icon on a home screen — then
    upload() for a file off an <input>: where the bytes are served back
    from, the attachment and image rows it writes, the 20 MB ceiling and the
    downscale under it, and a gallery that never shows one picture twice.
---

# Files and pictures

Files are the one thing an app's store cannot hold directly: you cannot write
raw bytes into a component the way you write a title or a number. This page
covers files: how you write the app's own files with `app_files`, then what
`upload` accepts and returns for the files its visitors send, where those are
served from, the two rows one upload writes, who may send and who may look, the
20 MB ceiling and the downscale that gets a phone photo under it — and a gallery
that does not show the same picture twice.

## app_files, and what a write returns

The app's own files — `index.html`, the css and js beside it, `vocab.json` — are
written with `app_files`. **Writing a file needs no `op`**: give it a `path` and
a `content`, or a list of them as `files: [{path, content}, …]`.

**Every write reports what was stored**, so a file transcribed by hand is
checked in the call that made it rather than once the app serves broken:

    wrote index.html → https://yourname.yaks.fyi/recipes/index.html
      — 4213 bytes, sha256 9f2a…

A `.json` file is parsed as well, and the reply reports `parsed` — or reports
that it is not valid JSON, and where:
`NOT valid JSON — Expected ',' or '}' after
property value in JSON at position 45971`
names the bracket that is wrong. That is a note in the reply, not a refusal: the
file is still stored.

**`op: patch` edits one file in place** — `path`, `find`, `replace`. `find` is
exact text, not a pattern, and must match exactly once; anything else refuses
saying how many matches there were, so lengthen `find` until it names one place.
An empty `replace` removes the text. It is the cheap fix for the one line that
came out wrong, where the alternative is re-sending the whole file.

**`op: fetch` writes a URL's body to a path** — `path` and `url`, https only,
under the same 20 MB ceiling. It is how a third-party library is vendored into
the app rather than transcribed:

    app_files(app, op: 'fetch', path: 'chess.js',
              url: 'https://cdnjs.cloudflare.com/…/chess.min.js')
    → fetched … → https://yourname.yaks.fyi/chess/chess.js — 15234 bytes,
      sha256 3c1f…, text/javascript, integrity sha256-PB8…=

The `integrity` is the same digest written the way a `<script integrity>`
attribute expects, so a page that goes on loading the file from a CDN can be
pinned to the bytes this fetch got. The content type the app serves it as comes
from the path's extension, not from the response — name it `.js` and it is
javascript.

## Every write keeps what it replaced

**No write and no delete throws bytes away.** Before anything lands at a path,
whatever was there is kept — addressed by its own sha256, beside the app — and
noted in that path's history. So the file you just overwrote is one call away,
and you never have to remember what it used to contain.

    app_files(app, op: 'history', path: 'index.html')
    → index.html in yourname/recipes:
      now — 4213 bytes, sha256 9f2a…
      - until 2026-09-06T14:20:11Z — 3980 bytes, sha256 c41d…, by Jeff
      - until 2026-09-04T09:02:47Z — 1204 bytes, sha256 7b19…, by Jeff

An entry reads **"the file was these bytes until then"** — the time and the name
are the write that took them away. So the top entry is one step back from now,
and `now` is what the app is serving this second.

**`op: restore` puts one back.** With just a `path` it is the newest entry —
undo the last write:

    app_files(app, op: 'restore', path: 'index.html')

`sha` names one exactly, off the history. `at` asks for a moment —
`at: '2026-09-05T00:00:00Z'` puts the file back to what it was serving then,
which is the useful form when what you know is _when_ it was still right and not
_what_ it contained.

A restore is **itself a write**, so the bytes it replaces are kept in turn and a
restore can be undone by another. Nothing rewrites the history.

The bytes are kept for **30 days**, and always at least the last 20 versions of
a file, whichever reaches further back. After that a version is let go — and
only ever a version nothing else needs: bytes a kept deploy names are never
removed, so `app_rollback` keeps working for every version `app_versions` still
lists.

This is the file-sized way back. Beside it: `app_rollback` puts back every file
of a whole deploy at once, `store_restore` puts back everything the app has
_saved_, and `app_restore` brings back an app that was deleted.

## An icon for the home screen

Write an **`icon.png` beside `index.html`** — square, 512×512, on its own
background rather than transparent — and the app has an icon everywhere. The
file is the setting; there is nothing else to call. Bytes are not text, so it
goes in as `base64` in place of `content`, or with `op: fetch` from a URL:

    app_files(app, files: [{path: 'icon.png', base64: '<the bytes>'}])

The page is served with the two links an installed app needs already in its
head, at the app's own root:

    <link rel="apple-touch-icon" href="/recipes/icon.png">
    <link rel="manifest" href="/recipes/manifest.webmanifest">

iOS reads the first and nothing else — an icon named only in a manifest is not
the one it uses — while everything else reads the second, which is generated
from the app: its title as the `name`, its own address as `start_url` and
`scope`, `display: standalone`, the icon at 512 and 192, and the colour the page
sets in `<meta name="theme-color">` where it sets one.

**What the page declares is kept.** Each link is added only where the page has
none, so an app that writes its own `apple-touch-icon` keeps it and still gets
the manifest link, and an app that ships its own `manifest.webmanifest` is
served that file rather than the generated one.

Until an app writes an icon, `icon.png` serves the platform's own default tile,
so an app somebody keeps on their phone is never a blank square. Transparency is
the one trap: iOS fills a transparent icon with solid black, so give the picture
a background of its own.

## upload, and what it returns

`upload` comes from the client library every app is served:

    import { upload } from './api/client.js'
    let file = await upload(input.files[0])

It accepts a `File` from an `<input type=file>`, or any `Blob` — one a canvas
made, one a `fetch` returned. A second argument names it:
`upload(blob, { name: 'cake.jpg' })`. A `File` carries its own name; a bare
`Blob` has none unless you give it one.

It returns one object:

    { eid:   '9f2a…',                      // the bytes' own address
      url:   '/photos/api/blob/9f2a…',     // where this app serves them
      mime:  'image/jpeg',                 // the type the browser reported
      bytes: 51234,                        // how many there are
      w: 1600, h: 1200 }                   // pictures only

`mime` is the blob's own `type` — the browser fills it in for a file off an
input — and `application/octet-stream` when the blob has none. `w` and `h` are
there only when the bytes are a picture that declares its size; otherwise both
keys are simply absent.

## Content addressing, and what follows from it

`eid` is the SHA-256 of the bytes, in hex. Nothing else decides it: not the
name, not the mime, not who sent it. Three things follow.

**The same file twice is one upload.** Send the same photo from two pages, two
devices, or twice from one form, and the second send lands on the same eid, the
same object, and the same row — so a page need not remember what it has sent; it
may send again and read what comes back.

**The bytes at an address can never change**, so `GET ./api/blob/<eid>` is
served `cache-control: public, max-age=31536000, immutable`.

**The address is not a secret.** Anyone who may read the app may read any blob
in it, and someone holding the identical file can compute its eid without
asking. So never treat a blob address as a permission to hold something private.
What content addressing does not save is the sending: the upload endpoint reads
every byte to hash them, so re-uploading a 4 MB photo costs 4 MB every time.

## The two rows one upload writes

The upload endpoint writes two entities through the app's own `/apply` endpoint,
recorded as written by the person who uploaded — so `.created!` names them the
way it names any other row. The **content** row sits at the bytes' own eid and
records what is true of the bytes; the **use** row records the name this app
gives them:

    { entity: { eid: '9f2a…' },
      blob:  { bytes: 51234 },
      image: { w: 1600, h: 1200 } }        // pictures only

    { entity: { eid: '…derived…' },
      attachment: { blob: '9f2a…', mime: 'image/jpeg', name: 'cake.jpg' } }

The use row's eid is derived from the content's, as
`sha256("attachment:" + sha)`, so it is stable without being the same eid. That
is what makes a second upload of the same bytes rename the row that already
exists rather than write a second one holding the same facts. A component may
not point at its own entity, which is the other reason the two are apart.

The rename is a patch: an upload that names nothing keeps the name it had. Send
`cake.png`, then the same bytes as `the cake.png`, then a third time off a
canvas with no name at all, and the row's name is `the cake.png`.

**List the use, not the content.** `query('.attachment!')` is every file the app
holds:

    for (let f of await query('.attachment!')) {
      draw(`./api/blob/${f.attachment.blob}`, f.attachment.name)
    }

`.blob!` is not that list — a long `doc.body` is a blob row too.

## A picture's width and height

`image` sits on the content row — at the very eid your own row points at —
because a size is a fact about the bytes, however many attachments name them.
The platform reads it out of the file's own header and never by decoding the
image: png, jpeg, gif and webp each declare their size in the first few dozen
bytes. Anything else gets no `image` at all — a guess would be worse than
nothing, since a page can measure the bitmap itself but cannot tell that a
stored number is wrong. A header declaring a zero is a broken file, and gets
none either.

So a wall holds each photo's space open before a single byte of it arrives:

    let size = new Map((await query('.image!'))
      .map((i) => [i.entity.eid, i.image]))
    let box = size.get(p.photo.blob)              // the eid the row holds
    if (box) { img.width = box.w; img.height = box.h }

Two attributes and the browser reserves the right rectangle, so the page does
not jump as the pictures land; `aspect-ratio: ${box.w} / ${box.h}` does the same
for a fluid layout. The header is all the platform reads — no orientation, no
capture date, no camera.

## Drawing a picture from a row

Point your own row at the bytes by their eid:

    await apply({ photo: { caption: 'the cake', blob: file.eid } })

`photo` is the app's own component, declared in its `vocab.json`:

    { "$defs": {
        "photo": { "properties": {
          "caption": { "type": "string" },
          "blob":    { "type": "string" } } } } }

An app's own columns are `text`, `number`, `bool`, `time` or `url`, so a blob's
address is held as text. Then build the address from the eid, relative:
`img.src = './api/blob/' + p.photo.blob`.

`file.url` is fine to drop straight into an `<img src>` the moment the upload
returns — it is the picture the person just picked, on the page they picked it
on. It is the wrong thing to save in a row: it is path-absolute and carries this
app's slug (`/photos/api/blob/9f2a…`), so a row holding it breaks the day
someone installs a copy of the app at another address.

## The HTTP endpoints underneath

`upload` is one POST, and the bytes come back from one GET — ordinary
same-origin HTTP, which `curl` or a worker can call too.

    POST ./api/blob
    content-type: image/jpeg     ← the blob's own type, minus parameters
    x-yak-name: cake.jpg         ← optional, percent-encoded, then the bytes
    → {"eid": "9f2a…", "url": "/photos/api/blob/9f2a…",
       "mime": "image/jpeg", "bytes": 51234, "w": 1600, "h": 1200}

    GET ./api/blob/9f2a…    → the bytes

A header is ASCII and a file's name is not, so the name is sent percent-encoded
and the endpoint decodes it; the mime is kept to 120 characters and the name to
200. The GET returns the bytes with the mime and the name from the attachment
row, the immutable cache header,
`content-security-policy: sandbox; script-src 'none'` and
`x-content-type-options: nosniff` — an uploaded HTML page or SVG opened in a tab
cannot run anything. A path that is not 64 hex characters, or bytes this app
never received, is `404 no_such_file`.

## Who may upload, who may read

Uploading is a write and reading the bytes is a read, so the app's `access`
decides both, exactly as it decides `apply` and `query`: on `public` anyone with
the link reads and an owner or editor uploads; on `open` anyone with the link
does both, so a guest at the party adds their own photo without signing in; on
`private` members read and an owner or editor uploads.

Ask before they pick, not after they wait:

    let who = await me()
    if (!who.writes) show(`<a href="${who.signIn}">Sign in to add one</a>`)

A refused upload throws an error carrying the platform's own message:
`sign in to change this app` to a stranger, with `signIn` on it, and, to a
member who is only a viewer,
`you can read this app but not change it — its owner can make you an editor`:

    try { await upload(file) }
    catch (e) { e.signIn ? location = e.signIn : show(e.message) }

On an `open` app a signed-out guest's upload has no `created.by` at all. If the
wall wants a byline, ask them for a name and save it in your own row.

## What deleting removes

Deleting the app (`app_delete`) puts it in the trash and keeps every byte of it
for 30 days, so `app_restore` brings the uploads back with everything else.
After that — or straight away, with `forever: true` — the bytes go: every object
under the app's own prefix, uploads included, and there is no undo. Nothing else
removes bytes. Tombstoning your `photo` row removes your row. Tombstoning the
content row at the sha removes the `blob`/`image` row and cascades to the
`attachment` row naming it — and the bytes stay in the bucket, still served at
`./api/blob/<eid>` to anyone holding the address, now with no mime or name to
serve them with. An upload is permanent for the life of the app.

## 20 MB, and the downscale

**One upload is 20 MB at most.** The upload endpoint refuses twice — once on the
`content-length` before it reads anything, once on the bytes it got — with
`413`, code `too_large`, and a message a guest can act on: "that file is too big
to send — try a smaller one". An empty body is a 400, code `no_bytes`. A phone
photo is routinely over the ceiling, so downscale before you send:

    // A picture, at most 1600px on its long side, as a jpeg.
    let smaller = async (file, max = 1600) => {
      if (!file.type.startsWith('image/')) return file
      let bmp = await createImageBitmap(file)
      let scale = Math.min(1, max / Math.max(bmp.width, bmp.height))
      let cv = new OffscreenCanvas(
        Math.round(bmp.width * scale),
        Math.round(bmp.height * scale),
      )
      cv.getContext('2d').drawImage(bmp, 0, 0, cv.width, cv.height)
      bmp.close()
      return await cv.convertToBlob({ type: 'image/jpeg', quality: 0.85 })
    }

    await upload(await smaller(file), { name: file.name })

Three things worth keeping in that shape. `Math.min(1, …)` never blows a small
picture up. A `Blob` off a canvas has no name, so pass the original's, or the
attachment row will have none. And resized bytes are new bytes: a downscale that
picks a different `max` or `quality` next time hashes differently and uploads
again, so pick your numbers once and leave them alone.

A second `413` is possible on a free-tier space out of room — code `space_full`,
with a message naming the space; nothing about the file will fix that one. There
is no server-side resizing, so the page's downscale is the whole of it.

## Dropping files, and more than one

A file input is one way in; a drag-and-drop is the other. Both hand you a
`FileList`:

    let took = async (files) => {                 // one at a time
      for (let file of files) await add(file)
    }
    drop.addEventListener('dragover', (e) => e.preventDefault())
    drop.addEventListener('drop', (e) => {
      e.preventDefault()
      took(e.dataTransfer.files)
    })

`dragover` must call `preventDefault` or the browser opens the file instead of
giving it to you, and uploading one at a time keeps a dropped folder of twenty
photos from making twenty requests at once.

## A gallery, whole

Pick, downscale, upload, look before writing, then draw the wall with each
picture's space held open — the entire page.

    <!doctype html>
    <meta charset="utf-8" />
    <title>The wall</title>
    <input type="file" id="pick" accept="image/*" multiple />
    <p id="say"></p>
    <div id="wall"></div>
    <script type="module">
      import { apply, me, query, subscribe, upload }
        from './api/client.js'

      let pick = document.getElementById('pick')
      let wall = document.getElementById('wall')
      let say = (words) => document.getElementById('say').textContent = words
      // smaller() is the downscale from the section above.

      // Only offer the input to someone this app accepts a write from.
      let who = await me()
      if (!who.writes) {
        pick.remove()
        say(who.signIn ? 'Sign in to add a photo.' : 'Look only.')
      }

      let add = async (file) => {
        try {
          say(`sending ${file.name}…`)
          let sent = await upload(await smaller(file), { name: file.name })
          // The bytes dedupe themselves; a row of ours does not. Look
          // before writing one, or the photo hangs on the wall twice.
          let [seen] = await query(`.photo.blob=${sent.eid}`)
          if (!seen) {
            await apply({ photo: { caption: file.name, blob: sent.eid } })
          }
          say('')
        } catch (e) {
          e.signIn ? location = e.signIn : say(e.message)
        }
      }

      pick.addEventListener('change', async () => {
        for (let file of pick.files) await add(file)
        pick.value = ''
      })

      // Each picture's size, by the eid the rows already hold.
      let size = new Map((await query('.image!'))
        .map((i) => [i.entity.eid, i.image]))

      // And the wall, redrawn on every change — including one made on
      // their phone while this page is open.
      subscribe('.photo!', (photos) => {
        wall.replaceChildren(...photos.map((p) => {
          let img = document.createElement('img')
          img.src = `./api/blob/${p.photo.blob}`
          img.alt = p.photo.caption || ''
          let box = size.get(p.photo.blob)
          if (box) { img.width = box.w; img.height = box.h }
          return img
        }))
      })
    </script>

The line worth keeping is the check before the write: two visitors sending the
identical picture are one blob and one `attachment` row for free, but a `photo`
row of your own is a separate row, and two of them are two pictures on the wall.
On a wall left open for hours, read `.image!` inside the subscription, so a
photo added later gets its box too.

## What files do not do

- **No server-side resizing and no thumbnails.** What you upload is what is
  served, at one size. The page downscales.
- **No listing of the bucket** and **no deleting bytes** short of deleting the
  app. `.attachment!` is the listing; bytes nobody wrote a row about are
  reachable only by their address.
- **No `image` for anything but png, jpeg, gif and webp** — a pdf, an svg or a
  heic uploads fine and simply carries no size.
- **No progress events.** `upload` returns when it is done, so a page shows
  progress per file, not within one.

The whole guide, everything else an app can do, is at
<https://yaks.fyi/docs.md>.
