Minimal web file access + stateless HMAC share links over a plain directory tree. One static Go binary, forward-auth only.
  • Go 84.4%
  • JavaScript 8.2%
  • Shell 5.9%
  • Python 1.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
casey ace8aa43a6
Some checks failed
release-on-merge / release (push) Failing after 21s
release-on-merge / deploy (push) Has been skipped
Merge pull request 'shelf §32: calm cards, and how long (v0.32.0) — RELEASES AND DEPLOYS on merge' (#67) from bizon-agent/feat-v1.32-calm-cards into main
Reviewed-on: #67
2026-10-23 00:15:28 +00:00
.forgejo build, ci, test: one binary everywhere — the release builds and publishes shelf and its checksum only (gate 86 reads the workflow), smoke-tests the parsers as subcommands and fails on an extra asset; ci-gates builds one and holds cmd/ to an allow-list of one; dev-build, dev-serve and both harnesses run the built-ins with no --filter; the real Radicale gate checks the log says so 2026-10-19 08:42:45 -05:00
cmd/shelf fix(filter): a replacement for a built-in must be an executable file at startup; --no-builtin normalises the extension as --filter does; gate 87 covers vcard; gate comments say what they rest on 2026-10-19 08:48:42 -05:00
internal fix(§32): unticking All day while choosing the last day gives 1 hour, not a time with no end; a missing or backwards end is said; the time rows' words are labels — from the two cold reviews; gates 107–109 widened 2026-10-22 09:28:48 -05:00
scripts chore(dev): a run leaves nothing behind — dev-check runs in a box when there is one; the browser harness starts Chrome without the session bus and checks it stayed in the run's cgroup 2026-10-20 08:12:15 -05:00
tests fix(§32): unticking All day while choosing the last day gives 1 hour, not a time with no end; a missing or backwards end is said; the time rows' words are labels — from the two cold reviews; gates 107–109 widened 2026-10-22 09:28:48 -05:00
.gitignore build, ci, test: one binary everywhere — the release builds and publishes shelf and its checksum only (gate 86 reads the workflow), smoke-tests the parsers as subcommands and fails on an extra asset; ci-gates builds one and holds cmd/ to an allow-list of one; dev-build, dev-serve and both harnesses run the built-ins with no --filter; the real Radicale gate checks the log says so 2026-10-19 08:42:45 -05:00
CLAUDE.md test(§32.6): browser gates 107 (one box, labels beside, colour for state) and 108 (How long); v0.32.0 2026-10-22 08:54:00 -05:00
go.mod
README.md feat(gui), test(browser), docs: portrait keeps the wrapping shelf; gates 99–102 in senior.mjs at a phone held sideways and a tablet; shelf.mjs follows §28; README, CLAUDE.md; VERSION 0.30.0 2026-10-19 17:34:39 -05:00
SPEC.md fix(§32): unticking All day while choosing the last day gives 1 hour, not a time with no end; a missing or backwards end is said; the time rows' words are labels — from the two cold reviews; gates 107–109 widened 2026-10-22 09:28:48 -05:00
VERSION test(§32.6): browser gates 107 (one box, labels beside, colour for state) and 108 (How long); v0.32.0 2026-10-22 08:54:00 -05:00

shelf

A small HTTP file shelf: browse and download one directory tree in a browser, behind an authenticating reverse proxy, and hand a single file to someone who has no account via an unguessable expiring link. Single static binary, Go standard library only, no database, no accounts of its own.

Status: v0.26.0 — browse, download, share links, upload (files, a whole folder, or a photograph from the phone's camera) + mkdir, on-the-fly format conversion through external filters, systemd socket activation, a gallery layout with a tile cache, a previewer, the photo surface (timeline, sidecar, transforms), the notes surface, and since v0.12.0 the simple shell (§16): the version on every page, the controls above a scrolling listing, Upload/New dialogs, dotfiles folded away until ?dotfiles, and a timeline that follows the pointer. v0.13.0 added the camera picker (§16.9), v0.13.1 made d download the focused tile (§13.6), v0.14.0 made one timeline over a whole subtree (§14.12), v0.15.0 gave that timeline its own previewer — the lightbox (§14.13) — and took the caption and editor row off the tile, v0.15.1 made the apply history keep the original's capture date (§14.6.2), and v0.16.0 fixed the crash of a large recursive timeline, made the axis drag-only, and gave the lightbox a filmstrip and a full-screen level (§14.14), v0.17.0 added the glance: a colour sketch of every picture while you scroll, with no request (§14.15), v0.18.0 gave every directory page a view bar, a folder rail and one settings control (§17), v0.19.0 added pan and zoom to both previewers (§19), v0.20.0 fixed the previewer's shape and gave it its editor (§20), v0.21.0 put copy-on-write with an audit trail under every write and added the four verbs and ?versions (§21 step 1), v0.22.0 added the notes editor and the simple home (§21 steps 2 and 4), and v0.23.0 added the contacts and calendar surfaces over Radicale's own files, read-only, with shelf-dav published beside the other two binaries (§18; until v0.28.0, which folded all three into one binary, §24), and v0.24.0 made them editable through Radicale's own socket (§21 step 3), and v0.24.1 closed two exposures: a file's script no longer runs as the viewer, and only shelf's own pages can post (§22.7), and v0.25.0 replaced the folder rail with Places, drew only the views that work, and put Home one click away (§23), and v0.26.0 added kiosk mode: a frame with Home and Back around a simplified shelf, for someone who should never see the file tree (§22), and v0.27.0 drew the home as a shelf of books — Contacts, Calendar, Notes, Files, Pictures, then the extensions — and gave every tenant two interfaces, simple and normal, with a switch and a folder tree (§25), and v0.28.0 folded shelf-note and shelf-dav into the one shelf binary as shelf filter note|dav, on by default (§24), v0.29.0 let any folder's timeline take in its subfolders (§27), and v0.30.0 redrew the simple and kiosk pages for an old reader: one bar, one row, one typeface, a shelf that fits any screen with a picture on every spine, and captions written under the picture (§28). The frozen scope lives in SPEC.md; read that before changing behaviour — it is amended first and implemented second, and its status paragraph says what has shipped.

Why it exists

Serving a directory is easy; serving one without accidentally serving the rest of the filesystem is the whole job. shelf puts every filesystem access behind a single os.Root (Go 1.24), so .., absolute paths and symlinks that leave the root are refused by the kernel-facing layer rather than by string handling of our own.

Install

Each release publishes a static linux/amd64 binary and its checksum as release assets (.forgejo/workflows/release-on-merge.yml, built in the pinned Go image, CGO_ENABLED=0; see Releasing — the tag is created by the merge, not pushed by hand):

tag=v0.1.0
base=https://git.varasys.io/varasys/shelf/releases/download/$tag
curl -fLO $base/shelf-$tag-linux-amd64
curl -fLO $base/shelf-$tag-linux-amd64.sha256
sha256sum -c shelf-$tag-linux-amd64.sha256
install -m 0755 shelf-$tag-linux-amd64 /usr/local/bin/shelf

Verify the checksum before installing — an automated deployer must (an Ansible get_url without a checksum: silently skips an existing file, so a version bump installs nothing while reporting success).

Build

Requires Go 1.24 or newer (os.Root).

CGO_ENABLED=0 go build -o shelf ./cmd/shelf

Run the checks the way CI does — the same file CI runs, from the repository root. CI runs it inside the image pinned in scripts/go-image; ./scripts/dev-check.sh does the same with podman, and with any other Go it is a useful check but not CI's answer (go version is printed first so the log says which):

./scripts/ci-gates.sh   # vet, test, build the one binary, gofmt (must print nothing)

Developing (on the workstation)

Development happens on the operator's workstation (bizon), checkout /home/user/src/shelf. That machine has no Go installed, on purpose: builds run in the same container CI uses, pinned by digest in scripts/go-image (gate 0 of scripts/ci-gates.sh fails if any script or workflow names another), so a workstation cannot drift from the pipeline. What it does have is rootless podman and google-chrome (for the optional browser gate).

./scripts/dev-fixture.sh   # make ./fixture — a synthetic tree, under a second
./scripts/dev-build.sh     # build ./bin/shelf in the pinned toolchain
./scripts/dev-check.sh     # CI's gates (scripts/ci-gates.sh) in CI's image, then the workstation gates
./scripts/dev-serve.sh     # serve ./fixture on 127.0.0.1:8091; Ctrl-C stops it

Every script takes DEV_DRY_RUN=1 and then prints the commands it would run instead of running them. dev-build.sh stamps -X main.version=dev-<short sha> (plus -dirty on an uncommitted tree), so shelf --version always distinguishes a workstation build from a release, which stamps the tag.

SHELF_NATIVE_GO=1 uses a native go instead of the container. It is explicit and never a silent fallback: a build that quietly used a different toolchain is how a red CI run becomes a surprise.

Testing discipline — synthetic first

Every feature is developed, and every gate measured, against a tree dev-fixture.sh made. No development shelf is ever pointed at the owner's files — not at the live tree, not at a copy of it, not at a mount of it. The real library enters the loop exactly once, at the end: the operator beta-tests the released binary on the real server (sync.<domain>), where it is the fileserver's own ZFS snapshots that are the safety net, and where §7 means shelf can only ever add — it never deletes and never renames — so a beta test costs at most a rollback.

That division is simpler than any arrangement for making real data safe to develop against, and it is also safer: the safety stops depending on a development script getting a mount option right.

The fixture is not a weaker test root than a copy of the real library either. For everything except "does it look right with my pictures" it is a stronger one: it is a few MB rather than 48 GB, it is reproducible from a seed, and it contains the awkward cases on purpose rather than by accident.

internal/**/*_test.go the rules — path safety, the gates, the surfaces' parsing. Each builds its own tree in a t.TempDir(), so the unit suite needs no fixture and no network
internal/notefilter/testdata/ the note parser's corpus: front matter, a note with no heading, #tag lookalikes in code fences and URLs
internal/davfilter/testdata/ the vCard/iCalendar corpus (§18.7 gate 8) with exact goldens: folded lines, the escapes, a VERSION:2.1 card refused, a calendar in a .vcf, a TZID, an all-day event, a weekly rule with UNTIL and EXDATE, an override, a to-do
tests/stress/ the stress gate (§14.14.5), run deliberately, not by dev-check: a seeded 19,000-picture YEAR/MM library served COLD with the real Pillow wrappers, driven through convergence, a scroll, an axis pass and a lightbox walk. STRESS_URL=… STRESS_COOKIE=<file> runs the same measurement read-only against a live shelf
tests/dav/ dev-check gate 7 (where podman and the Radicale image exist): editing contacts and calendar through shelf against a real Radicale 3.7.5 over a unix socket, the forms posted as a browser posts them, and no file under Radicale's storage owned by shelf's user (§21.14)
tests/filters/ dev-check gate 6 (where Pillow exists): runs the COPIED production wrappers: the --full rendition keeps the date (§14.6.2) and the meta wrapper emits the glance under --preview (§14.15, gate 42) — green since the copies were refreshed from myos #460
tests/browser/ the optional headless-browser gates (dev-check.sh gate 5, run only where Chrome and that directory exist): the mounted-tile measurement, the lightbox walk, the pan-and-zoom gestures (§19) the previewer's shape and form (§20), the editor and the shelf (§21, §25), the switch and Folders (§25), and §18.7 gate 10 (a 1,600-card book converging, the weekly rule's occurrence count, the "not expanded" mark), over a synthetic library the harness builds itself — it has to, because what it measures is size; cdp.mjs is the one CDP client they share
./fixture/ the whole-program tree the SURFACES are developed against: a gallery, a timeline, a notes tree, and the artefacts that live beside them

Nothing in that table is the owner's data, and that is the point.

dev-fixture.sh — the synthetic tree

./scripts/dev-fixture.sh                              # ./fixture: 3000 photos, 12 years, 300 notes
./scripts/dev-fixture.sh /tmp/f --photos 200 --notes 20
./scripts/dev-fixture.sh --seed 7 --end 2026-01-01    # byte-identical, every machine, forever
It writes Which rule that exercises
camera/ + .shelf-gallery a designated gallery — the settings file, gallery = on (§12.3)
~3000 JPEGs with real EXIF DateTimeOriginal spread over 12 years the timeline (§14.2)
a handful of *-undated.jpg with no EXIF date the mtime fallback and its f: true flag
one shared mtime on every dated photo the failure §14.2 names — a whole decade collapsing onto one migration date if capture dates were ever ignored
camera/.shelf-meta.json the authored half: hide, caption, a transform (§14.4, §14.5) — and a key this version does not understand, so "an unknown key is preserved" has something to preserve
camera/trip-2014/ a nested gallery: the surface has content at a depth, which is what §14.1.2's "link where the content is" is about
notes/ — [[links]] that resolve and that do not, #tags, front matter, a note with no heading the notes surface (§15)
notes/house.sync-conflict-…-DEV.md the artefact §15.8 says to make louder
.stfolder/, .stignore, .stversions/old.md, .syncthing.garden.md.tmp, .shelf-upload-….partial the artefacts §15.8 says are absent from the surface while still listed by §7 — folded away by default and shown by ?dotfiles (§16.4)
docs/ files no filter is declared for — a named tile, not a broken image
vault/ an empty directory

The JPEGs are real JPEGs and carry real EXIF, with no toolchain at all. Four ~670-byte images (encoded once, offline, by Go's image/jpeg) are embedded in the script as base64; the EXIF is built in the script byte by byte — an APP1 segment whose 64-byte TIFF block holds exactly one tag, DateTimeOriginal in the Exif sub-IFD, which is what the production extractor reads. So "give this photo that capture date" is a 20-byte string substitution, the whole generation loop is shell builtins with no forks, and no Pillow, no python, no Go, no container and no network is needed to make a fixture. 3000 photos takes under a second.

--seed fixes everything; --end pins the newest capture date (it defaults to today, so the timeline's recent end is populated). The script is idempotent by wiping and regenerating DIR, and the wipe is guarded by a .shelf-fixture stamp it wrote itself — point it at a real tree by mistake and it refuses, because that tree has no stamp. ./fixture/ is gitignored.

Beta testing on the real server

There is no --real mode and no way to aim a development shelf at the owner's files. The released binary is what meets real data, on the server, after the merge deploys it — see Releasing. Three things make that an acceptable place to find the last problems, and none of them is a script being careful:

  • shelf can only add. §7 limits v1's mutations to upload and mkdir: no delete, no rename, no overwrite of an existing file's bytes or metadata. The worst a bad build does to the tree is leave something in it.
  • The storage tier keeps snapshots. supernas/sync is on ZFS with sanoid snapshots and a pull replica, so "undo whatever that build did" is a rollback, not a restore.
  • Everything else was already measured on the fixture, which contains the cases a real library only has by accident.

Serving it

dev-serve.sh serves ./fixture (or DEV_ROOT=<dir>, another generated tree) on 127.0.0.1:8090, running as you — in production shelf runs as the uid that owns the tree it serves (§11), and the faithful reproduction of that over a tree you generated is to run as yourself, which is also why nothing in this path needs sudo. It puts the HMAC key and the tile cache in its run directory, outside --root, because §12.4 makes a cache inside the served tree a startup error and the same reasoning covers the key. If the tree is not there yet, it runs dev-fixture.sh first.

It then stands a throwaway Caddy on 127.0.0.1:8091 whose only job is header_up Remote-User casey. That is not decoration: the GUI plane has two gates (§3.1, §11.2) — the peer address, and the Remote-User header a reverse proxy writes — and a browser cannot set a header, so a browser talking straight to :8090 gets a correct and thoroughly confusing 403. Open http://127.0.0.1:8091/. The proxy is the dev stand-in for Authelia and nothing else about it matters.

Filters are declared only if their programs are actually present, and the skipped ones are printed: a filter that is declared and not runnable is a 502 per request, found by a reader rather than at startup.

./scripts/dev-serve.sh stop tears everything down from another terminal; Ctrl-C does the same from the one running it. Both kill shelf by listening port — never pkill -f, which matches the ssh or sudo command line that started it and kills your own shell.

From the workstation to production

Step Who
open the pull request (git push origin HEAD:refs/for/main -o topic=…, the AGit flow), bumping VERSION if this should ship — that bump is the release request you
approve the PR's CI run (Forgejo holds runs from the restricted agent account until a maintainer does) the operator
ci.yml runs the gates (scripts/ci-gates.sh) on the PR's head commit, refs/pull/<n>/head automated, on every PR
merge, once that run is green the operator
release-on-merge.yml runs the same gates on the merged commit — a red gate releases and deploys nothing — then creates the tag and the release from the merged commit and publishes shelf-<tag>-linux-amd64 with its .sha256 automated, on the merge
its deploy job rings deploy-shelf@fileserver <tag>; the server verifies the release and the checksum, installs the binary and restarts the units automated, on the release
verify at https://sync.<domain> you

Nobody pushes a tag and nobody converges by hand. The merge is the release trigger and the release deploys itself — see Releasing for what the workflow does, why a merge rather than a tag, and the operator's one-time step to install the deploy credential. This table is the walk-through; that section is the source of truth.

There is one binary: the note and vCard/iCalendar parsers are subcommands of it (shelf filter note|dav, §24), so there is one version and one checksum, and the installer verifies the binary against its .sha256; a binary without a matching checksum is a file that gets skipped while the install reports success. (Until v0.28.0 a release published three binaries, and the installer verified each.) Run ./scripts/dev-check.sh before the push — it runs the same scripts/ci-gates.sh, so a red CI run means something the workstation could not see, not something it did not look at.

Run

install -m 0600 /dev/null /etc/shelf/share.key
head -c 32 /dev/urandom > /etc/shelf/share.key

./shelf \
  --root /srv/shelf \
  --gui-listen 127.0.0.1:8080 \
  --share-listen 127.0.0.1:8081 \
  --trusted-proxies 10.20.30.10/32,10.20.10.8/32 \
  --hmac-key-file /etc/shelf/share.key \
  --share-url-base https://sync.example.com \
  --share-max-ttl 720h \
  --max-upload 268435456 \
  --filter "md:html=/usr/bin/pandoc -f markdown -t html"

Leave --hmac-key-file and --share-url-base off and shelf browses and downloads with sharing switched off: no control in the listing, and /s/… answers 404 like anything else.

Flags

Every flag has a SHELF_* environment fallback; the flag wins when both are given.

Flag Env Default Meaning
--root SHELF_ROOT (required) Directory to serve. All filesystem access is confined to it.
--gui-listen SHELF_GUI_LISTEN 127.0.0.1:8080 Listen address of the private GUI plane: host:port, or unix:/absolute/path.sock. Ignored for a plane systemd socket-activates.
--share-listen SHELF_SHARE_LISTEN 127.0.0.1:8081 Listen address of the public share plane.
--trusted-proxies SHELF_TRUSTED_PROXIES (empty) Comma-separated CIDRs (a bare IP is accepted and means a single host) allowed to reach the GUI plane over TCP. Empty means nobody — the GUI plane rejects every request.
--trusted-peer-uids SHELF_TRUSTED_PEER_UIDS (empty) Comma-separated uids or usernames allowed to reach the GUI plane over a unix socket, where there is no address to check. Empty means nobody. Names are resolved once at startup, so an unknown one is a refusal to start.
--max-upload SHELF_MAX_UPLOAD 0 Maximum size in bytes of one uploaded file — and the switch for the whole write surface: zero disables POST /api/upload and POST /api/mkdir, and the listing then offers no controls for either.
--allow-edit SHELF_ALLOW_EDIT false Turn on the four verbs that change or remove a file a human put in the tree — edit, delete, rename, restore — and the ?versions page (§21). Off means they do not exist (404). Needs --max-upload too. Every change, with or without this flag, keeps the bytes it displaces in .shelf-versions/ and is recorded.
--edit-ext SHELF_EDIT_EXT md,txt Extensions the notes editor (?edit) opens, comma-separated, without dots. Empty disables the editor; the verbs stay on.
--dav-socket SHELF_DAV_SOCKET (none) Absolute path of the tenant's Radicale unix socket (§21.6). With --allow-edit and --max-upload, contacts and calendar can be edited through it; shelf forwards Remote-User as X-Remote-User. Never probed at startup: a socket that is gone hides the controls and makes a save a 502.
--hmac-key-file SHELF_HMAC_KEY_FILE (empty) Key file for share tokens. If set, the file must exist, be a regular file, be mode 0600 and be non-empty, or the process refuses to start. Its raw bytes are the key — 32 bytes from /dev/urandom is the recommendation. Empty means no link can be minted or served.
--share-url-base SHELF_SHARE_URL_BASE (empty) Public origin links are built from, e.g. https://sync.example.com. Must be an absolute https URL with a host and no credentials, query or fragment; a path prefix is allowed. Empty disables share creation — shelf cannot guess the name the world reaches it by. Requires --hmac-key-file.
--share-max-ttl SHELF_SHARE_MAX_TTL 720h (30 days) Longest lifetime a link may be given. A longer request is clamped to it, and the response says so.
--filter SHELF_FILTER (none) Declare one conversion: "<src-ext>:<dst-format>=<argv...>". Repeatable (the env variable holds one spec per line; any --filter flag replaces the whole env list). Destination formats: html, txt, pdf, json, thumb, meta, note, vcard, ical. A --filter for md:note, vcf:vcard or ics:ical replaces shelf's built-in parser, and its program must be an executable file or shelf refuses to start (§24).
--no-builtin (none) (none) Switch off one of shelf's built-in parsers without replacing it: md:note, vcf:vcard or ics:ical. Repeatable; each at most once, and not also given a --filter (§24).
--filter-timeout SHELF_FILTER_TIMEOUT 30s Hard limit on one conversion. The filter is killed past it and the request answers 502.
--filter-max-output SHELF_FILTER_MAX_OUTPUT 67108864 (64 MiB) Largest converted document to hold and serve. Exceeding it fails the request; output is never truncated.

Endpoints

GUI plane (--gui-listen)

  • GET / and GET /any/sub/dir — server-rendered HTML listing: name, size, modification time, directories first, link to the parent.
  • GET /any/file — download. Content-Disposition: inline by default, attachment with ?dl=1. That second spelling is the whole of "the download URL": the previewer's download link, and the data-dl a grid tile carries so the d key can follow it (§13.6).
  • GET /any/file?format=<dst> — the file converted by a declared filter (below).
  • GET /any/file?view — the previewer: one image with prev/next arrows, keyboard navigation, the caption under it and, where writes are on, its editor (SPEC.md §13, §20). Needs a declared thumb filter, else 404.
  • GET /any/file?note — one markdown note described by the declared note filter, as JSON, cached like a tile (§15.4).
  • GET /any/file?hash — the file's content hash, which /api/photo-apply requires back.
  • GET /any/dir?meta — the directory's images described by the declared meta filter, as JSON, for the client-side timeline (§14.2).
  • GET /any/dir?photos — the photo surface: the script-required timeline page (§14.1).
  • GET /any/dir?photos&tree / ?meta&tree — the same surfaces over the whole subtree rather than one directory (§14.12), so a library foldered YEAR/MM/DD is one timeline. Since v0.29.0 (§27) it is honoured on any directory, the top one included; the page has an Include subfolders / This folder only switch. The walk is bounded (16 levels, 10,000 folders, 50,000 pictures, set by a measurement, §27.8), and a walk a bound cut answers "limits" naming it and the page says so above the timeline.
  • GET /any/dir?notemeta — the per-directory note index, as JSON (§15.4).
  • GET /any/dir?notes — the notes surface: the script-required notes page (§15.2).
  • GET /any/file?vcard / ?ical — one contact or one calendar object described by the declared vcard / ical filter, as JSON, cached like a tile (§18.4); 404 beneath any dot-directory (§18.6).
  • GET /any/dir?contactmeta / ?calendarmeta — the per-directory candidate lists the two pages fetch first; no filter runs for them.
  • GET /any/dir?contacts / ?calendar — the contacts and calendar surfaces: two more script-required pages, read-only (§18.2, §18.3). The calendar's month is the URL's hash (#2026-03).
  • POST /api/share — mint a share link (below).
  • GET /any/dir?grid / ?list — the directory as a grid of tiles and icons, or as the table, for this request only (§17.2). The folder's .shelf-gallery decides the view when the address names none; these override it and write nothing.
  • GET /any/dir?dotfiles — the listing with its dotfiles shown; by default they are folded away and the page says how many (§16.4).
  • POST /api/upload?dir=<path>[&overwrite=1] — upload one or more files, a folder, a photograph from the phone's camera, or a new file from a form (below). One endpoint; the spellings of the body differ, the write path does not.
  • POST /api/mkdir?dir=<path> with name — create a directory (below).
  • POST /api/photo-meta — write one image's authored state (hide, caption, transform); a JSON body from the timeline, or the previewer's form (name plus exactly one of save+caption, hide, rotate, undo; back=view returns to the picture, §20.2) into the directory's .shelf-meta.json sidecar (§14.4).
  • POST /api/gallery?dir=<path> with key (gallery|recursive) and value (on|off) — rewrite one line of that directory's .shelf-gallery, keeping every other line, comment and key; a form that answers 303 back to the page (§17.6). recursive=on is refused at the served root.
  • POST /api/photo-apply — render a transform to a duplicate, or over the original with --allow-in-place (§14.6).
  • POST /api/edit, /api/delete, /api/rename, /api/restore — with --allow-edit only: change, remove, rename or bring back a file, each keeping what it displaces and recording itself (below, §21).
  • GET /any/file?edit — with --allow-edit, --max-upload and the file's extension in --edit-ext: the notes editor, one big textarea with save and delete (§21.5). Works with scripting off.
  • GET /?shelf — the shelf: five books and the extensions (§25, below). /?home, its old name, answers 308 to it. GET /?files is the top folder's listing in every mode; / alone is the shelf in simple mode.
  • POST /api/ui — mode=simple|normal: the switch. Sets the shelf-ui cookie and returns to the same place in the other mode; writes no file (§25.6).
  • POST /api/dav?dir=<collection> — with --allow-edit, --max-upload and --dav-socket: op = put (with name and base, or kind for a new item), delete or restore (with version); a form or JSON. Writes nothing in the tree but the version store; Radicale writes the item (§21.14, below).
  • GET /collection/item.vcf?edit, GET /collection/?edit=contact|event — the item forms.
  • GET /any/dir?versions — with --allow-edit only: that directory's kept versions, newest first, each with a restore button (§21.2).
  • GET /.shelf-versions/<path> — one kept version's bytes, always as a download.
  • GET /healthz — 200 ok.

The ?view, ?photos, ?notes, ?contacts and ?calendar pages are separate doors onto the same directory, so the plain listing keeps working with scripting off. ?view and ?photos answer 404 unless a filter is declared and --thumb-cache-dir is set; ?notes, ?contacts and ?calendar always render, and are empty until their filter is declared. A directory holding a .shelf-gallery file with gallery = on is listed as a grid of ?format=thumb tiles instead of a table (§12), and one saying recursive = on opens its timeline over the whole subtree by default (§14.12, §27).

Places, Folders, the view bar, and the one settings control (v0.18.0, §17; v0.25.0, §23; v0.27.0, §25)

In the normal interface, beside the content, Places lists where things live: Shelf, the shelf's five books (greyed with the reason when one is missing), then the extensions. Under it, Folders is the tree from Top down to the folder you are in, every folder on the way open; any other folder is closed and opens in place. It reads at most 50 directories beyond your path per page; past that a folder is a plain link, so the cut-off shows. Both stack above the content on a phone. The shelf name at the top of every page goes to the shelf, and the path starts at Top (/?files).

Every directory page also carries a view bar — list · grid · photos · notes · contacts · calendar — showing only the views that have something in this folder, the one you are on marked. A folder where only the listing applies has no bar at all. list and grid are the same folder drawn two ways; photos is the timeline, notes the notes surface, and contacts and calendar the Radicale pages. Upload here and New folder or note say what they do; hidden files (names starting with a dot) are counted in a quiet line under the listing, with a link to show them. All of it works with scripting off.

Where writes are enabled the bar also carries up to two buttons that edit the folder's .shelf-gallery: default: grid / list, and timeline: this folder only / include subfolders. They rewrite one line and keep everything else in the file. The subfolder switch is never offered at the top of the shelf, and elsewhere only where the folders below hold pictures — a timeline spanning two trees that share pictures (a hard-linked camera folder, say) would show them twice, silently, so shelf does not offer that scope as a button.

Pointing the recursive timeline at a library

.shelf-gallery defines exactly two keys. For a library foldered by date, the file goes at the ROOT of that library and says:

# photos/.shelf-gallery — one timeline over YYYY/MM/DD
gallery = off
recursive = on

gallery = off because a directory of year folders is not itself a grid; recursive = on because the pictures are below it. Since v0.29.0 the marker is a default: the links to this folder's timeline (the view bar, Places, the shelf's Pictures book) open the subtree, and the page's switch goes to This folder only. Any folder's subtree can be asked for without it. An empty .shelf-gallery still means gallery = on and not recursion.

Two things to know about a subtree timeline, whichever way it was asked for:

  • The walk is bounded (16 levels, 10,000 folders, 50,000 pictures). A walk that hits a bound shows less than the tree holds, and the page says which bound, above the timeline.
  • A picture stored twice is shown twice. shelf does not de-duplicate (§27.6): two copies, or two hard links to one file, are two pictures, which is how duplicates are found.

An older shelf reading recursive logs an unknown key and serves the flat surface, so the marker can be placed before the version that understands it is deployed.

The lightbox — stepping through the timeline (v0.15.0, §14.13)

A plain click on a timeline tile opens the picture in the page, over the timeline. → and ← (also l/h, j/k, space) step to the next and previous picture in the timeline's order — newest first by capture date, across every folder of the subtree — so the last picture of 2019/03 is followed by the first of 2019/02, which is what the tile to its right already was. g/G jump to the ends, d downloads, ? shows the keys, and Esc (or u, or the close button, or the browser's Back) returns to the timeline with the picture last shown focused, so the tile keys continue from it. The URL fragment names the open picture, so a reload or a shared link reopens it.

The bar carries the position, the path, download, a link to the ?view previewer (where the share form is), and — where writes are enabled — the editor that used to sit under every tile: the caption, rotate, undo, hide. Nothing about writing changed: the same POST /api/photo-meta, the same sidecar beside the pictures.

The tile itself is now just the picture. Its tooltip is the caption (when there is one) and the path; the cc button in the when-bar overlays captions on every tile that has one, off until asked and remembered per browser. ~ (grouped by mtime, no capture date in the file) and ↻ (a provisional transform; the original is untouched) are small badges in the corner. Hovering a tile with a mouse reveals rotate and hide; on a phone those are not drawn — tap the picture and edit in the lightbox.

Under the picture, a filmstrip shows the two pictures before and the two after; click one to jump to it. f (or the "full screen" button) is a third level: the picture alone on the whole screen, arrows still stepping; Esc leaves full screen, and Esc again closes the lightbox (v0.16.0, §14.14).

The glance (v0.17.0, §14.15): while you fling through the years, every tile shows a blurred 3×3 colour sketch of its picture — sky, snow, night, indoors — with no request to the server; the real thumbnail replaces it when the scroll pauses. The sketch comes from the metadata extractor and rides in ?meta, about 34 bytes a picture. It needs the meta wrapper change (in myos since #460, 2026-10-17) and --preview on the meta declaration, as the fileserver and dev-serve.sh both declare it:

jpg|jpeg|png : meta = shelf-meta --preview

Adding the flag re-reads every picture once — minutes, cold, on a large library, shown as "reading …" — because the cache key covers the declaration.

The time axis is dragged (v0.16.0): hovering magnifies it so you can read the dates, and only a press-and-drag moves the grid — so reaching for the scrollbar no longer sweeps the timeline across a decade.

?view is unchanged and still steps through one folder in name order: it is the previewer for the listing and the grid, and each previewer walks the order of the page it was opened from.

Pan and zoom (v0.19.0, §19)

Both previewers — ?view and the lightbox — answer the gestures every picture viewer shares. The wheel zooms about the pointer (Ctrl+wheel too, which is what a trackpad pinch sends); a drag pans while zoomed in; a click on the picture toggles between fit and 1:1 at the point clicked (a double click is one toggle); a pinch zooms about the fingers; one finger swipes to the previous or next picture at fit and pans while zoomed. The keys are + (or =), - and 0 for in, out and fit; ? lists them. The picture is never shown smaller than it fits, never leaves a gap at an edge, and arrives fitted on every step. Nothing is fetched: a zoom is a transform of the image already on the page, so 1:1 shows the file's real pixels, and the server did not change. On the lightbox a click on the picture used to step forward; it now zooms, as in every viewer the vocabulary comes from — the arrows, the keys, the filmstrip and a swipe step. With scripting off ?view is the page it was: the picture is a plain link to the file.

The previewer's shape and its editor (v0.20.0, §20)

?view has the shape every picture viewer settled on: a slim one-line bar above (position, first, last, download, the share form), arrows at the left and right edges of the stage for previous and next, and under the picture a fixed-height row with the file name, the caption and the picture's marks (hidden, rotated 90°). Nothing on the page moves between pictures: the header, the arrows, the stage and the row are the same rectangles for every picture in a folder, whatever its name, position or caption — measured by gate 48. The crumbs are the folder's; the file name is in the row.

Where writes are on, the row is the editor: the caption field (Enter saves), rotate, hide/unhide and, on an edited picture, undo — the lightbox's four verbs, to the same endpoint. On this page it is an ordinary form, because the previewer's script makes no request by rule (§13.2) and the page works with scripting off; a save reloads the picture, which the browser has cached. The form's rotate keeps a crop or scale the entry already has. No key writes: h is still previous.

A hidden picture is shown and marked, not skipped: the previewer walks every picture of the folder, as the grid lists them, so the one page that can unhide a picture can reach it. The grid now dims a hidden picture too (as the timeline does with "show hidden" on), so the grid, the previewer and the timeline agree about which pictures are hidden. The list view does not change: a file is a file.

Captions are never written into an image. They live in .shelf-meta.json (§14.4), which is the "transform file". A caption in EXIF would mean rewriting the file's bytes, which §7 forbids.

An apply currently costs the timeline the capture date (found while settling the above, measured 2026-09-22): the production thumb wrapper saves renditions with no EXIF, so after an apply (§14.6, either mode) the new file has no DateTimeOriginal, ?meta falls back to its mtime — the apply time — and the picture moves to today marked ~. The original's bytes survive (in .shelf-originals/ for in-place; untouched for duplicate), so nothing is lost, but shelf could not put the date back. Two things follow, §14.6.2:

  • since v0.15.1 the sidecar's history[].from records the original's date (as the meta document carried it; omitted when the file had none) and mtime, so "when was this really taken" is in the tree — recorded, never used to date the timeline;
  • ./tests/filters/run.sh (dev-check gate 6, where Pillow exists) runs the copied wrapper over a JPEG with EXIF and asserts the date survives and the pixels come out upright with Orientation reset. The fix (one exif= on the save, with exif[274] = 1) landed in myos #460 on 2026-10-17 and the copy in scripts/dev-filters/ was refreshed from the fileserver.

All six API endpoints are routed on method and path, so a real file called api/upload in the root is still listed and downloadable with GET.

Share plane (--share-listen)

  • GET /s/<token>/<filename> — the shared file, Content-Disposition: attachment, Range supported.
  • GET /healthz — 200 ok.
  • Everything else, and every failure — 404 with an identical body.

What a browser may and may not do with the GUI plane (v0.24.1, §22.7)

  • A file someone put in the tree cannot run as you. Every raw file shelf serves carries Content-Security-Policy: sandbox: an .html or .svg still renders, but its script does not run and it has no access to shelf. PDFs are the one exemption (Chrome's viewer will not open sandboxed).
  • Only shelf's own pages can change anything. A POST that the browser marks as coming from another site (Sec-Fetch-Site other than same-origin, or a foreign Origin) is 403 before it reaches any endpoint. Scripts and curl, which send neither header, are served as before; a JSON body must say Content-Type: application/json (415 otherwise).
  • shelf can be framed only by itself (frame-ancestors 'self' on every answer).

A link is <share-url-base>/s/<token>/<filename>:

https://sync.example.com/s/<payload>.<signature>/nested.txt
                            |         |          └ the real basename: cosmetic, but checked
                            |         └ base64url(HMAC-SHA256(key, payload))
                            └ base64url(path NUL decimal-unix-expiry)

e.g. .../s/c3ViL25lc3RlZC50eHQAMTgwMDAwMDAwMA.<43 chars of MAC>/nested.txt
           ^ that payload decodes to  sub/nested.txt\0 1800000000

The token carries the file path and the expiry, signed with the key; nothing is stored when a link is created, so there is no share database to back up or leak. The trailing file name is cosmetic — the bytes come from the signed path — but it must be the real basename, so a link describes itself and cannot be retitled by whoever passes it on.

Revocation is rotation. Replace --hmac-key-file and restart: every outstanding link dies at once. There is no per-link revocation, because that would be state.

Minting from the listing is a plain form (share column → pick a lifetime → link). From a script:

curl -sS -X POST https://shelf.internal.example/api/share \
  -H 'Content-Type: application/json' \
  -d '{"path":"sub/nested.txt","ttl":"24h"}'
{"url":"https://sync.example.com/s/c3Vi….RUZv…/nested.txt","path":"/sub/nested.txt",
 "expires":"2026-08-15T09:41:07Z","ttl":"24h0m0s","clamped":false}

ttl is a Go duration and is required — shelf does not guess how long a file should be exposed. A value above --share-max-ttl is clamped, and the reply says so ("clamped":true plus a message). A form post gets the same thing as an HTML page; send Accept: application/json or a JSON body to get JSON. The request goes through the GUI plane's two gates like everything else, and the user who minted the link is logged with the path and expiry — that log line is the audit trail. The token is never logged.

Uploads and directories (M3)

shelf may create; since v0.21.0 it may also change and remove, but no change ever destroys a byte, and every change is recorded (SPEC.md §21, next section). Both mutations here are off until --max-upload is set to a positive size.

curl -sS -X POST -H 'Accept: application/json' \
  -F file=@report.pdf \
  'https://shelf.internal.example/api/upload?dir=/reports'
{"path":"/reports/report.pdf","name":"report.pdf","size":18422,"replaced":false}

curl -sS -X POST -H 'Content-Type: application/json' \
  -d '{"name":"2026"}' 'https://shelf.internal.example/api/mkdir?dir=/reports'
{"path":"/reports/2026","name":"2026"}
  • The file streams into a temporary .shelf-upload-<random>.partial in the destination directory, and is renamed into place only after a complete read, an fsync and a close. So a reader never sees a partial file under the real name, a failed upload leaves nothing behind, and two writers racing for one name leave one whole file rather than a blend. (Rename is atomic only within a filesystem, which is why the temporary is not in /tmp.)
  • An existing name is refused with 409; repeat with ?overwrite=1 to replace it. The file replaced is kept in .shelf-versions/ first (§21.1.1). A name that appears while the upload is streaming is refused at the commit, never overwritten.
  • Over --max-upload is refused with 413, and nothing is written. The cap is per file: a request may carry several file parts (a multi-file picker, or a folder), and each is stored atomically in turn; the first failure stops the request, names the file, and says how many were stored before it — those stay (§16.3).
  • A folder arrives as relative paths. A browser's folder picker names each part folder/sub/file.jpg; every segment must be a plain name (below) and the directories are created on the way, so the file lands exactly where the name says. A file in the way of a directory is a 409.
  • A new file from a form: post name and text as an ordinary form (or JSON) body to /api/upload and the text becomes the file — the same endpoint, the same create-only rule.
  • A photograph, taken on the spot. The upload dialog's third picker carries accept="image/*" capture="environment", so a phone opens its camera and hands the picture back as an ordinary file part (§16.9). There is no endpoint, no parameter and no new write path behind it: it is the same multipart request the other two pickers send, and it meets the same refusals. The camera app names the file — usually IMG_1234.jpg — and shelf cannot rename it (§7), so a collision is the same 409 as ever. Whether the phone also keeps a copy in its own gallery is the platform's decision, not shelf's.
  • Each name must be a plain name — no \, no .., no empty segment, no control characters, at most 255 bytes a segment — for both endpoints; mkdir takes exactly one segment. A dotfile is a fine name; . and .. are not names. shelf never stores a file under a name the client did not send: a path is honoured segment by segment or refused.
  • ?dir= goes through the same containment as a browsed path, so a traversal in it is refused exactly like one in a URL.
  • A browser form gets 303 back to the listing; send Accept: application/json (or a JSON body, for mkdir) for the object above.

Changing and removing files — copy-on-write with an audit trail (v0.21.0, §21)

The rule shelf keeps: it may change and remove content, but no change ever destroys a byte, and every change is recorded. Two pieces, both plain files in the served tree:

  • .shelf-versions/ at the root mirrors the tree. Before anything replaces, deletes or renames a file, the file gets a second name there — <dir>/<name>~<YYYYMMDD-HHMMSS>Z~<sha256 first 12 hex><ext>, named by the hash of the bytes it actually holds. A replace swaps the new file in atomically and keeps the old one; a delete moves the file into the store; a rename and a restore keep a copy, so no live file ever shares its bytes with a version. If the old bytes cannot be kept, or are not the ones you read, the change is refused and nothing moves. Nothing can write into the store but the store's own code. shelf never removes anything from the store; it is never listed, never a surface and never on the share plane.
  • .shelf-versions/audit.jsonl, append-only, one JSON object per change — who (Remote-User), when, op, path, old and new size and sha256, the version's name, the base it was checked against — and the same fields as one msg=audit line on stderr, which is the journal under systemd. Never the file's content.
# the hash the edit is based on
curl -sS 'https://shelf.internal.example/notes/shopping.md?hash'
{"name":"shopping.md","size":412,"sha256":"3fa9…"}

curl -sS -X POST -H 'Accept: application/json' \
  --data-urlencode name=shopping.md --data-urlencode base=3fa9… --data-urlencode text@new.md \
  'https://shelf.internal.example/api/edit?dir=/notes'
{"op":"edit","path":"/notes/shopping.md","version":"notes/shopping.md~20261017-140211Z~3fa9c2d1e0b7.md","sha256":"…"}
  • Which writes are covered. All of them (§21.1.1): upload with ?overwrite=1, the photo sidecar and the gallery dotfile, photo apply (a second in-place apply keeps the intermediate it replaces), and the four verbs. Upload and mkdir record a create.
  • The four verbs need --allow-edit and --max-upload. edit (name, base, text), delete (name, base; a directory only when empty, no base), rename (name, base, to, within one directory, never over an existing name), restore (version, and base when the name exists now). Bodies are forms or JSON; JSON may send the base as If-Match. A browser form gets 303 back to the listing (back=versions returns to ?versions).
  • base is the hash you read (GET <file>?hash). If the file changed since — another tab, syncthing — the answer is 409 "this file changed since you opened it; reload and try again" and nothing is written. An edit that changes nothing is 400.
  • Names with a dot anywhere on their path are refused by the verbs: shelf's furniture, syncthing's and Radicale's, and the store itself.
  • ?versions lists a directory's kept versions from the store, marks a name that no longer exists deleted and a version the log does not mention unrecorded (a crash between a change and its record), and links each version's bytes. Folders that were renamed or deleted are linked too (?versions&gone=<name>), so their history stays one click away.
  • Nothing is pruned. Retention is the filesystem administrator's, outside shelf.

The notes editor (v0.22.0, §21.5)

/notes/shopping.md?edit is one large textarea holding the file, a save button and a delete this note button — plain forms over /api/edit and /api/delete, so every rule above applies: the old text is kept, the change is recorded, and a save over a file that changed since the page was opened is refused with "this file changed since you opened it; reload and try again" (your text is still in the box). A save lands back on the folder's notes page, or its listing where there is no notes page. The listing and the notes page show an edit link beside each file the editor opens (--edit-ext, default md,txt). Line endings are kept as the file had them: a browser sends a textarea's lines as CRLF, and an LF note stays LF. A file that is not UTF-8 text, or is larger than --max-upload, gets a page saying why it cannot be edited here, with its download link.

The shelf, and the two interfaces (v0.27.0, §25)

/?shelf is the simple home, drawn as a sketch of a shelf with the tenant's name on the plank. Its five books are the basic system, found or set by a .shelf-ui at the served root:

mode     = simple            # the tenant's default interface: simple or normal (normal)
title    = Richard's shelf   # the nameplate (else .shelf-kiosk's title, else "Shelf")
notes    = /notes/           # the Notes book's folder (default /notes/)
pictures = /photos/          # the Photos book's timeline (unset: greyed)
dav      = /dav/             # where Radicale's collection-root is (default /dav/)
contacts = auto              # auto: the person's first address book, by its props; or a /path/
calendar = auto              # the same, for a calendar

In the simple and kiosk drawings the books are Contacts, Calendar, Notes and Photos; the normal drawing's shelf adds Files, the top folder. A book with nothing behind it is a dashed outline saying why, never a dead link. A notes folder that does not exist yet still opens, where the shelf may create files: its first note makes it. Every line of .shelf-extensions is an extension, drawn after a bookend (at most 12; a destination is a path beginning with one / or an http/https URL, and anything else is not drawn, with the reason logged):

# label = destination | picture
Family Photos = /photo-kiosk/kiosk_bundle/index.html | frame

Each book carries a line drawing above its title. An extension names one from shelf's own list after a | — contacts, calendar, notes, photos, frame, book — and without one, or with a name not on the list (logged), it is drawn as a plain book. Held sideways, the whole shelf is drawn as one picture scaled to fit the screen; held upright, the books keep their size and wrap onto planks.

.shelf-home was renamed .shelf-extensions in v0.27.0; shelf no longer reads the old name.

Simple and normal. In simple mode / is the shelf. Every page behind a book has one bar across the top: a large ‹ Shelf on the left (‹ Contacts, ‹ Photos … on a page opened from one), the page's picture and name in the middle, and its one action — Add, New note, Change — on the right. Every item is a bordered card with a ›; nothing moves by itself; the type is Atkinson Hyperlegible Next, served by shelf itself at /?font=regular|bold (internal/gui/fonts/, SIL OFL). Contacts is a plain alphabetical list with a page per contact whose numbers can be tapped to call; Calendar is a list of the days ahead starting with Today; Notes is a list of notes, New note asks for a title and the note, and a note opens ready to write in; Photos is months of pictures, and a picture opens between a large Previous and Next with its caption underneath, where Write a caption writes it. No breadcrumbs, paths, version line, Places, Folders, view bar, Share, Versions or hidden-files line are drawn. Normal is the file tree up front, as before, with Places and Folders. A person switches with Simple at the top right of a normal page, or Full view at the bottom right of the simple shelf, which sets a cookie for a year; without one the tenant's mode decides. shelf never writes .shelf-ui or .shelf-extensions.

Kiosk mode (v0.26.0, §22)

For a person who should only ever see the shelf. A .shelf-kiosk file at the served root puts that tenant's whole shelf inside a frame:

title = Richard's shelf             # the window's title, and the nameplate unless .shelf-ui sets one
app   = /photo-kiosk/kiosk_bundle/  # a folder served as a web app; one line each

/?kiosk is the shelf, full screen. Shelf's own pages draw the simple drawing's bar (above); over anything that does not — an app — the frame's own bar holds one big Back to shelf. Every other page a browser opens directly is sent there, so a bookmark, a typed address or the return from sign-in always lands on the shelf; nothing inside the frame can open a window, start a download or replace the frame. The pages inside are the simple drawing, with no Upload/New in a listing and no download links. When the session runs out, the next Back to shelf or page goes to sign-in and comes back to the shelf. An app folder (Joel's photo viewer) runs its own script, its requests fenced to its own folder by a Content-Security-Policy: it loads its files and its database, and its own calls to shelf's endpoints are refused. It runs as the signed-in person on shelf's address, though, so a script written to get around the fence could; whoever can write that folder is trusted (§22.6). Point the kiosk browser at /?kiosk. Without .shelf-kiosk nothing changes.

Format conversion (v1.1)

A file can be served through an external command you declared, chosen by its extension and the requested format:

--filter "md:html=/usr/bin/pandoc -f markdown -t html"
--filter "md:pdf=/usr/local/bin/md2pdf"
--filter "csv:json=/opt/tools/csv2json --strict"

Then GET /notes/mydoc.md?format=html runs pandoc with the file on stdin and serves its stdout as text/html, named mydoc.html.

  • GUI plane only. ?format= is honoured behind the auth gates and nowhere else: the share plane never converts — it ignores the parameter and serves the file's bytes. Converters are large programs with long CVE histories, and no public link may reach an exec. This is the rule the whole feature is built around (SPEC.md §10).
  • An undeclared mapping is a 404, the same answer an unknown path gets. shelf does not publish which conversions it was configured with.
  • The plugin API is stdin → stdout. Nothing is loaded into the process, nothing is discovered, and there is no plugin interface: adding or removing a converter is a flag and a restart. No shell is involved — the argv is split on whitespace and executed directly, so there is no quoting, globbing or $(...). If you need any of that, write a two-line wrapper script and declare that. argv[0] must be an absolute path.
  • A conversion that fails is a 502 with the reason (and the filter's first line of stderr): a non-zero exit, a timeout, a missing binary, or output over the cap. The output is captured in full before a byte is sent, so a converter that crashes half-way gives you a clean error rather than a truncated document that looks complete.
  • The Content-Type comes from the destination format, through a fixed map (html, txt, pdf, json), never from what the child produced. Converted html is also served with Content-Security-Policy: sandbox.
  • The child is caged: --filter-timeout, --filter-max-output, a fixed minimal environment, and a working directory in the system temp dir.
  • A missing binary is discovered at request time, not at startup, for every filter you declare. There is no --filter-check on purpose: a binary can be installed, removed or replaced while shelf runs, so a startup check would prove something about the past and invite trusting it. The one exception is shelf's own built-ins (the notes, contacts and calendar parsers, below): shelf checks at startup that it can exec itself and refuses to start if it cannot.
  • Not included, deliberately: no cache of converted output, no chained conversions, no autodetection (the source is the file's extension, literally).

The notes surface (v1.6)

GET /any/dir?notes is the notes surface: a page that reads a directory of markdown as notes — title, tags, [[links]], backlinks, and a search over titles, headings, tags, names and excerpts. It is a second door, like the photo surface: a separate URL and a separate page, so the plain listing keeps working with scripting off (SPEC.md §15.2).

It needs the md:note filter, which shelf declares itself: with no flag, md:note maps to shelf filter note, run as a child process of shelf's own running image (/proc/self/exe) through the same filter cage as any other filter, so a parser that crashes or hangs is a killed child and a 502, never the server. There is nothing to build or declare, and the startup log has a msg=filter mapping=md:note from=built-in line. The only flags are for changing that:

shelf ... --filter "md:note=/abs/path/to/my-notes"   # replaces the built-in (logged)
shelf ... --no-builtin md:note                        # switches it off

shelf filter note (until v0.28.0 the separate shelf-note program) reads a note on stdin and writes one JSON object on stdout — {title, headings, tags, links, excerpt, words} — like every other §10 filter: shelf knows no markdown, it runs a command and labels the result. The excerpt is capped at 1 KiB per note, so a directory's index stays proportional to note count and not to prose length.

  • The ordinary listing links to it — a plain server-rendered <a>, offered wherever the surface would have content (a note candidate or a conflict copy) and nowhere else, so the listing still runs no script and the door never opens on an empty room (§15.2.1).
  • GET /any/note.md?note is that description for one file, cached exactly like a tile (--thumb-cache-dir, keyed on path, size, mtime and the filter's argv). GET /any/dir?notemeta is the per-directory list the page fetches first.
  • There is no editing. shelf may create and may never destroy (§7), and rewriting a note is destroying its previous content — so there is no save button, no locking and no conflict resolution. Notes are edited in your own editor, over sync or NFS.
  • "New note" is an upload, through the same /api/upload endpoint as everything else and with no overwrite flag: a create whose target exists is refused with 409 and the file on disk is untouched.
  • Search does not cover note bodies — deliberately (§15.5). Full-body search means either shipping every body to the browser or a server-side index, and neither is taken.
  • [[X]] names X.md in the same directory. A target that names anything else — another directory, a .. path — resolves to nothing and is shown as an unresolved link rather than a link that 404s.

Sharing the tree with syncthing — two deployment invariants

The notes tree is usually one end of a syncthing folder. shelf handles syncthing's artefacts itself: .syncthing.*.tmp, .stfolder, .stversions/ and .stignore are absent from the surface (the plain listing still shows them — it tells the truth about the directory), and a <name>.sync-conflict-<date>-<time>-<device>.md is surfaced under the note it is a copy of, flagged with the device and time, rather than indexed as a second note. shelf does not resolve a conflict for you; resolve it in your editor.

Three things must be set outside shelf, on both devices:

  1. ignorePerms=true on BOTH ends of the folder. With syncthing's default (ignorePerms=false) each file is written with the sender's mode, which will drop a workstation's 0664 into a tree whose posture is 0640. shelf never chmods — §7 forbids it touching an existing file's bytes or metadata — so the receiving side's umask and setgid directory have to be what decides the mode.
  2. .shelf-upload-*.partial in .stignore on both ends. shelf's own in-flight upload is visible in the directory while it runs (§7); a syncthing scan that catches it would ship half an upload to every device and then a delete when the rename lands. Same reasoning as never putting --thumb-cache-dir inside --root: what shelf writes for itself must not ride the sync.
  3. .shelf-versions/ in .stignore on both ends (§21.2): the version store is shelf's own record of this tree, not content to mirror.

The contacts and calendar surfaces (v1.18)

GET /any/dir?contacts and GET /any/dir?calendar read a directory of Radicale's own files — one .vcf per contact, one .ics per calendar object, as Radicale keeps them on disk — and draw an address book and a month calendar from them. Two more doors on the notes surface's model (SPEC.md §18): separate URLs, separate pages, the plain listing untouched and every item still an ordinary downloadable file. Discovery is by extension, never by path, so where the platform puts the collections does not matter to shelf.

They need vcf:vcard and ics:ical, which shelf declares itself, both mapped to shelf filter dav and run as a child of its own running image like the notes parser (nothing to build or declare). A --filter "vcf:vcard=<abs argv>" or --filter "ics:ical=<abs argv>" replaces the built-in for that mapping (the program must also answer the edit modes below, or editing is a 502); --no-builtin vcf:vcard or --no-builtin ics:ical switches one off.

shelf filter dav (until v0.28.0 the separate shelf-dav program) reads one item on stdin and writes one JSON object on stdout, naming the shape it found in kind (a .vcf holding a calendar is marked on the page, not misdrawn). A PHOTO is reported as present and never emitted; NOTE and DESCRIPTION are capped at 1 KiB; times pass through as written with their TZID, and the browser converts them with its own zone database — a zone it does not know is shown at its literal time and marked.

  • Radicale stays the only writer. shelf never writes a file in a collection and never takes Radicale's lock. Since v0.24.0 the pages can still edit — by asking Radicale (below). Every other write endpoint refuses Radicale's storage (§21.14).
  • Radicale's internals are not content. .Radicale.props, .Radicale.lock, .Radicale.cache/ (which holds a .vcf-named pickle per item) and the transient .Radicale.tmp-*/ are dot-named; the listing folds them as it folds every dotfile, and the six endpoints answer 404 beneath any dot-directory (§18.6), so the cache can never become a "collection".
  • The view bar gains contacts and calendar, each a link exactly where the directory holds a candidate and a plain word otherwise (§17.1's rule).
  • Recurrence is expanded in the page for FREQ of DAILY, WEEKLY, MONTHLY, YEARLY with INTERVAL, COUNT, UNTIL, BYDAY (weekly) and EXDATE; an event whose rule uses anything else is drawn once, at its start, with a visible "not expanded" mark. A missing occurrence must never look like no occurrence.
  • A torn read is one marked row. shelf reads without Radicale's lock; a single-item save is atomic on Radicale's side, a bulk import is not, and a file the filter cannot parse is a 502 for that item and a marked row on the page, re-read when the file next changes.
  • Measured (§18.9): the fixture's 1,601-card book converges in 2.8 s cold through the page's own request sequence; the browser keeps what it has read in IndexedDB keyed by name, size and mtime, so a revisit refetches only what changed.

Editing contacts and calendar (v0.24.0, §21.6, §21.14)

With --allow-edit, --max-upload and --dav-socket /run/radicale/<tenant>.sock, each card and event on the two pages gets an edit link and each page a new link. The forms are ordinary server-rendered pages (?edit on a .vcf/.ics, ?edit=contact / ?edit=event on a collection) that work with scripting off. A save is CardDAV/CalDAV over the socket, as the person:

  1. GET the item; its sha256 must be the base the form was drawn with (else 409);
  2. shelf filter dav turns the form into the item's new bytes, rewriting only what changed — every line it does not edit, a phone's grouped Apple label and properties it does not know, are copied as their original bytes; an unchanged form is 400 nothing changed;
  3. the item as it was goes into .shelf-versions/ at the collection's mirrored path;
  4. PUT (or DELETE) with If-Match on the GET's ETag — a phone that saved in between is Radicale's 412, shown as 409; a new item is If-None-Match: * with a random name;
  5. one op: dav record (collection, href, action, both ETags — never a field's value).

A deleted card is restored from the collection's ?versions page, through Radicale too. Without the socket the links are not drawn and /api/dav is 404; with the socket not answering they are not drawn and a save is 502. tests/dav/run.sh (dev-check gate 7) checks all of it against a real Radicale 3.7.5 in podman, including that no file under Radicale's storage is owned by the user shelf runs as.

Deployment model

browser ──▶ Caddy (TLS) ──▶ Authelia forward-auth ──▶ shelf GUI plane :8080
                                                       (Remote-User: alice)

internet ──▶ Caddy ──(allowlisted /s/ path only)──▶ shelf share plane :8081
  • The GUI plane is private. It is reached only through the reverse proxy, which authenticates the user (Authelia forward-auth) and sets Remote-User. shelf believes that header only when the TCP peer address is inside --trusted-proxies; the peer address always comes from the connection, never from X-Forwarded-For. Bind the GUI plane to an interface only the proxy can reach, and list the proxy's address.
  • The share plane is public, allowlisted at the origin to the /s/ path only. It has no identity gate — the signed token is the authorization. --share-url-base must be the origin that reaches this plane, not the GUI one.
  • Health probes. /healthz on the GUI plane is deliberately kept inside the trusted-proxy check: it is exempt from the Remote-User requirement (a probe has no user) but not from the address check. So the probe host's address must be in --trusted-proxies — e.g. the control node that runs the check — otherwise the probe gets a 403 and looks like an outage. The share plane's /healthz is ungated.

shelf writes structured logs to stderr (log/slog, text handler) and exits cleanly on SIGINT/SIGTERM, so it runs as a plain systemd unit with no wrapper.

Releasing

A merge to main is the release. Tags are no longer pushed by hand — the pipeline derives one from the tree, because main is protected and the instance signs its own UI merges, so a main tip is already an operator-approved, signed act. A hand-pushed tag is not, and it could not have chained into a deploy in any case: pushes made with the Actions token do not retrigger workflows.

To cut a release:

  1. Bump VERSION at the repository root in your pull request (0.10.0, no v). That bump is the release request, reviewed like any other line of the diff.
  2. The operator merges it. .forgejo/workflows/release-on-merge.yml first runs the gates (scripts/ci-gates.sh) on the merged commit and stops there if any is red — nothing is tagged, published or deployed. Then it reads VERSION, computes TAG=v$VERSION, and — only if that tag does not already exist — builds shelf-<tag>-linux-amd64 with its .sha256 file, smoke-tests shelf filter note and shelf filter dav through the released binary, creates the release through the API (Forgejo writes the annotated tag itself, pointing at the merged commit), uploads the two assets and reads them back (a missing or an extra asset fails it).
  3. It deploys itself. A second job sshes to the fileserver as deploy-shelf with the tag as its only argument. A forced command on the far end does the work: it fetches the release, verifies the signature and the published checksums there, installs the binary and restarts the units. Rollback and paging are the server's, not CI's.
  4. Verify at https://sync.<domain> — the footer/version endpoint answers with the tag, and shelf --version on the fileserver prints shelf v<VERSION>.

A merge that does not bump VERSION releases nothing. The tag-existence check makes the workflow idempotent, so docs-only merges pass through green and silently, which is the point — the alternative is a workflow that either fails on every ordinary merge or overwrites an artifact somebody already installed.

The operator's one-time step: the deploy credential

The doorbell needs two repository Actions secrets, SHELF_DEPLOY_KEY and SHELF_DEPLOY_CERT. Once, on the machine holding the YubiKey user CA:

  1. Generate a dedicated keypair for this purpose (ssh-keygen -t ed25519 -C shelf-deploy), used for nothing else.
  2. Sign it with the YubiKey user CA as principal deploy-shelf, with a force-command so the certificate can only ever run the fileserver's deploy script and a short validity. See the lab's runbook 52 for the exact signing line — the principal set, the force-command target and the validity window are the lab's to state, not this repository's to guess.
  3. Paste the private key into SHELF_DEPLOY_KEY and the -cert.pub into SHELF_DEPLOY_CERT in this repository's Actions secrets.

The certificate expires, and two things watch it: the lab's daily digest reports it between releases, and the deploy job re-reads the expiry from the cert on every run — it warns under 30 days and refuses to attempt the ssh once expired, so a dead credential says so by name instead of as Permission denied (publickey).

The matching @cert-authority lines for the host side live in .forgejo/known_hosts, committed in the clear: they are public keys, and a change to which authority CI trusts should be reviewed rather than edited silently in a secrets form.

Repository secrets are not exposed to workflow runs triggered by a pull request whose head is a fork — Forgejo's Actions documentation states that plainly, and the automatic token is read-only there. The deploy job does not rely on that: it is gated on on: push to main and on a release having actually been published.

If the secrets are missing, the release still publishes and only the deploy job fails, with that message.

Layout

cmd/shelf/         wiring: flags -> os.Root -> two listeners -> signal handling; also dispatches `shelf filter note|dav`
internal/notefilter/ the `md:note` filter (`shelf filter note`): markdown on stdin, one JSON object on stdout
internal/davfilter/  the `vcf:vcard` + `ics:ical` filter (`shelf filter dav`): one item on stdin, one JSON object out
internal/config/   flag + env parsing and validation
internal/httpx/    peer-address resolution and the trusted-proxy / peer-uid gate
internal/sdsock/   systemd socket activation: descriptors adopted by name, never position
internal/fsx/      containment: the ..-refusal and open-then-stat, shared by both planes
internal/token/    share tokens: canonical payload + HMAC-SHA256, no filesystem knowledge
internal/filter/   external filters: the --filter parser and the caged stdin->stdout runner
internal/thumbcache/ the tile cache: derived, disposable, size-capped, outside --root
internal/gui/      the private plane: listing, download, link minting, mutations, the
                   gallery grid, previewer, photo, notes, contacts and calendar surfaces,
                   /healthz
internal/share/    the public plane: one file by signed token

Notes

  • The module path is varasys.io/shelf. It is a binary, not a library, so the path is a stable name rather than a fetchable location; point it at the repository host if the module is ever meant to be go get-able.
  • Entries in a listing are what the directory contains, so a symlink that points outside the root is listed — following it returns 404. Containment is enforced at open time, not by hiding names.
  • A share link is resolved through the same os.Root and the same ..-refusal as a browsed path. The signature proves shelf minted the payload; it does not make the payload safe, and a key that ever leaks must not become a traversal primitive.
  • POST /api/share is routed on method and path, so a real file called api/share in the root is still listed and downloadable with GET. The same holds for /api/upload and /api/mkdir.
  • The commit step of an upload is a renameat(2) against the destination directory's own descriptor, because os.Root gained Rename only in Go 1.25 and this program is pinned to 1.24. On a non-Linux build shelf refuses to write rather than falling back to a path-based rename that would give up the containment property (SPEC.md §7.1).

Socket activation and multi-tenant deployment (v1.2)

shelf serves one root. Two people who must not see each other's files therefore get two shelves — separate processes, separate --root, separate uids — rather than one shelf that knows about users. The separation is then the kernel's file permissions, not a routing decision inside this program, and a bug here cannot cross it: the process serving one tenant cannot read the other's files at all.

To address N processes individually without giving each one a port, shelf accepts systemd socket activation:

# shelf-gui@.socket
[Socket]
ListenStream=/run/shelf/%i/gui.sock
FileDescriptorName=gui          # REQUIRED: descriptors are adopted by name, never by position
SocketUser=root
SocketGroup=caddy
SocketMode=0660
# shelf@.service
[Service]
User=shelf-%i
ExecStart=/usr/local/bin/shelf --root /srv/tenants/%i --trusted-peer-uids caddy
Sockets=shelf-gui@%i.socket

and the reverse proxy picks the socket from the identity it authenticated:

map {http.request.header.Remote-User} {tenant} {
    casey    casey
    richard  richard
    default  ""              # unknown user routes nowhere
}
reverse_proxy unix//run/shelf/{tenant}/gui.sock

Notes that matter:

  • FileDescriptorName= is required. A descriptor named anything else — including the socket unit name systemd falls back to — is a startup error, not an ignored socket. An unadopted socket that systemd still reports as active looks exactly like a working deployment.
  • The uid is the gate, not the socket's mode. Over a unix socket shelf checks the peer's uid from SO_PEERCRED against --trusted-peer-uids; --trusted-proxies does not apply and is reported as ignored. Set SocketMode=0660 anyway — defence in depth — but the program does not rely on it.
  • Both planes may be activated (FileDescriptorName=share for the other), or either one may fall back to its flag. A plane that systemd did not pass binds its flag as usual.
  • None of this is required. With no LISTEN_FDS in the environment shelf binds --gui-listen/--share-listen exactly as before, which is how a self-hoster runs one instance from a command line.