- Go 84.4%
- JavaScript 8.2%
- Shell 5.9%
- Python 1.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo | ||
| cmd/shelf | ||
| internal | ||
| scripts | ||
| tests | ||
| .gitignore | ||
| CLAUDE.md | ||
| go.mod | ||
| README.md | ||
| SPEC.md | ||
| VERSION | ||
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/syncis 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 /andGET /any/sub/dir— server-rendered HTML listing: name, size, modification time, directories first, link to the parent.GET /any/file— download.Content-Disposition: inlineby default,attachmentwith?dl=1. That second spelling is the whole of "the download URL": the previewer's download link, and thedata-dla grid tile carries so thedkey 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 declaredthumbfilter, else404.GET /any/file?note— one markdown note described by the declarednotefilter, as JSON, cached like a tile (§15.4).GET /any/file?hash— the file's content hash, which/api/photo-applyrequires back.GET /any/dir?meta— the directory's images described by the declaredmetafilter, 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 folderedYEAR/MM/DDis 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 declaredvcard/icalfilter, as JSON, cached like a tile (§18.4);404beneath 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-gallerydecides 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>withname— 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 (nameplus exactly one ofsave+caption,hide,rotate,undo;back=viewreturns to the picture, §20.2) into the directory's.shelf-meta.jsonsidecar (§14.4).POST /api/gallery?dir=<path>withkey(gallery|recursive) andvalue(on|off) — rewrite one line of that directory's.shelf-gallery, keeping every other line, comment and key; a form that answers303back to the page (§17.6).recursive=onis 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-editonly: 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-uploadand 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, answers308to it.GET /?filesis the top folder's listing in every mode;/alone is the shelf in simple mode.POST /api/ui—mode=simple|normal: the switch. Sets theshelf-uicookie and returns to the same place in the other mode; writes no file (§25.6).POST /api/dav?dir=<collection>— with--allow-edit,--max-uploadand--dav-socket:op=put(withnameandbase, orkindfor a new item),deleteorrestore(withversion); 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-editonly: that directory's kept versions, newest first, each with arestorebutton (§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[].fromrecords the original'sdate(as the meta document carried it; omitted when the file had none) andmtime, 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 (oneexif=on the save, withexif[274] = 1) landed in myos #460 on 2026-10-17 and the copy inscripts/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,Rangesupported.GET /healthz—200 ok.- Everything else, and every failure —
404with 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.htmlor.svgstill 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
POSTthat the browser marks as coming from another site (Sec-Fetch-Siteother thansame-origin, or a foreignOrigin) is403before it reaches any endpoint. Scripts andcurl, which send neither header, are served as before; a JSON body must sayContent-Type: application/json(415otherwise). - shelf can be framed only by itself (
frame-ancestors 'self'on every answer).
Share links
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>.partialin the destination directory, and is renamed into place only after a complete read, anfsyncand 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=1to 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-uploadis refused with413, 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 a409. - A new file from a form: post
nameandtextas an ordinary form (or JSON) body to/api/uploadand 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 — usuallyIMG_1234.jpg— andshelfcannot rename it (§7), so a collision is the same409as 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
303back to the listing; sendAccept: 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 onemsg=auditline 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 acreate. - The four verbs need
--allow-editand--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, andbasewhen the name exists now). Bodies are forms or JSON; JSON may send the base asIf-Match. A browser form gets303back to the listing (back=versionsreturns to?versions). baseis the hash you read (GET <file>?hash). If the file changed since — another tab, syncthing — the answer is409"this file changed since you opened it; reload and try again" and nothing is written. An edit that changes nothing is400.- 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.
?versionslists 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 anexec. 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
502with 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-Typecomes from the destination format, through a fixed map (html,txt,pdf,json), never from what the child produced. Convertedhtmlis also served withContent-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-checkon 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?noteis 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?notemetais 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/uploadendpoint as everything else and with no overwrite flag: a create whose target exists is refused with409and 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]]namesX.mdin 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:
ignorePerms=trueon 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's0664into a tree whose posture is0640. shelf neverchmods — §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..shelf-upload-*.partialin.stignoreon 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-dirinside--root: what shelf writes for itself must not ride the sync..shelf-versions/in.stignoreon 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 answer404beneath any dot-directory (§18.6), so the cache can never become a "collection". - The view bar gains
contactsandcalendar, 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
FREQofDAILY,WEEKLY,MONTHLY,YEARLYwithINTERVAL,COUNT,UNTIL,BYDAY(weekly) andEXDATE; 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
502for 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:
GETthe item; its sha256 must be thebasethe form was drawn with (else409);shelf filter davturns 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 is400 nothing changed;- the item as it was goes into
.shelf-versions/at the collection's mirrored path; PUT(orDELETE) withIf-Matchon theGET's ETag — a phone that saved in between is Radicale's412, shown as409; a new item isIf-None-Match: *with a random name;- one
op: davrecord (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.shelfbelieves that header only when the TCP peer address is inside--trusted-proxies; the peer address always comes from the connection, never fromX-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-basemust be the origin that reaches this plane, not the GUI one. - Health probes.
/healthzon the GUI plane is deliberately kept inside the trusted-proxy check: it is exempt from theRemote-Userrequirement (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/healthzis 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:
- Bump
VERSIONat the repository root in your pull request (0.10.0, nov). That bump is the release request, reviewed like any other line of the diff. - The operator merges it.
.forgejo/workflows/release-on-merge.ymlfirst 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 readsVERSION, computesTAG=v$VERSION, and — only if that tag does not already exist — buildsshelf-<tag>-linux-amd64with its.sha256file, smoke-testsshelf filter noteandshelf filter davthrough 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). - It deploys itself. A second job
sshes to the fileserver asdeploy-shelfwith 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. - Verify at
https://sync.<domain>— the footer/version endpoint answers with the tag, andshelf --versionon the fileserver printsshelf 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:
- Generate a dedicated keypair for this purpose (
ssh-keygen -t ed25519 -C shelf-deploy), used for nothing else. - Sign it with the YubiKey user CA as principal
deploy-shelf, with aforce-commandso 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. - Paste the private key into
SHELF_DEPLOY_KEYand the-cert.pubintoSHELF_DEPLOY_CERTin 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 bego 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.Rootand 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/shareis routed on method and path, so a real file calledapi/sharein the root is still listed and downloadable withGET. The same holds for/api/uploadand/api/mkdir.- The commit step of an upload is a
renameat(2)against the destination directory's own descriptor, becauseos.RootgainedRenameonly 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_PEERCREDagainst--trusted-peer-uids;--trusted-proxiesdoes not apply and is reported as ignored. SetSocketMode=0660anyway — defence in depth — but the program does not rely on it. - Both planes may be activated (
FileDescriptorName=sharefor 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_FDSin the environment shelf binds--gui-listen/--share-listenexactly as before, which is how a self-hoster runs one instance from a command line.