box — lightweight Drive over nginx

Goal: Rename padbox and generalize it into a lightweight self-hosted Drive: browse/upload/live-edit files behind the LAB_KEY gate, with a per-item share toggle that makes the same box.saah.as/<name> URL anonymously fetchable. box is a thin control plane; nginx serves all file bytes.

Background gate/exposure: security.md, drop-cloudflared-direct-exposure.md.

Core principle: app controls, nginx serves

box (the app) never streams file bytes — it does UI, upload, live-edit, and share/unshare. nginx serves every download from disk directly (sendfile, ranges, index resolution). This is faster, and a bug in the app can’t leak a file it wasn’t meant to serve.

Publish state IS filesystem state — no database:

data/files/<name>     every item, private by default (upload target, edit target)
data/public/<name>    symlink → ../files/<name> for shared items; presence = shared

Share = create the symlink; unshare = remove it. Sidebar badges an item if public/<name> exists.

One namespace, share as a property

Every item lives at box.saah.as/<name> (folders: box.saah.as/<folder>/...). The share toggle decides who can fetch that same URL:

box.saah.as/            [LAB_KEY gate] → proxy to box app   (UI)
box.saah.as/api/*, /ws  [LAB_KEY gate] → proxy to box app
box.saah.as/<path>      nginx try_files: data/public/ first (anonymous OK — shared),
                        else LAB_KEY gate → data/files/ (private); anon+unshared → 444

nginx mechanics: location / has root data/public; index index.html; try_files $uri $uri/ @private; — shared content (symlinked into public/) serves anonymously; the @private fallback applies the gate check and serves the full tree from data/files/. Key-holders see everything at the same URLs; anons see only what’s shared. Reserved top-level names: api, ws, gate (upload rejects them).

Same-origin isolation via CSP sandbox — load-bearing security. Shared HTML now runs on the app’s origin, and the browser attaches the (HttpOnly) gate cookie to same-origin requests — so an uploaded page’s JS opened by a key-holder could otherwise call /api/delete as them. Fix in one header: nginx adds Content-Security-Policy: sandbox allow-scripts to disk-served documents (.html/.htm/.svg/.xml via a suffix map). Sandbox without allow-same-origin puts the document in an opaque (null) origin: scripts run, but every request to box.saah.as is now cross-origin — no cookie attached, no response readable, no form posts. Same guarantee the separate-hostname design gave, zero extra hostnames. The app’s own UI is proxied from the app process (no CSP sandbox) and stays fully functional. Ceiling: a shared page needing localStorage/forms won’t work sandboxed; relax per-name with an explicit allowlist row if that ever matters.

No autoindex anywhere → the shared set can’t be enumerated; a shared item is reachable only by exact name (capability URL, same model as Immich share links). Optional random slug for “unlisted” shares.

Files and notes are one type

No separate “notes” concept. A file is a file:

  • Text (.md/.txt/.html/.css/.js/.json/...): opens in the editor pane, live-synced across open tabs via WebSocket. The current single global buffer generalizes to one room per file path; on connect the server sends the file’s current disk content, edits broadcast to peers, debounced save writes the file.
  • Binary: shows metadata (size, mtime) + view/download/share actions.

pad’s lone global buffer migrates to a scratch.md file — nothing lost.

Folder-sites: upload a .zip with “unpack” → app expands into files/<name>/, served at box.saah.as/<name>/ with nginx index index.html; sharing the folder shares the whole subtree.

UI (refined dark, single-file HTML in the app)

A redesign, not a reskin. Keep pad’s dark/mono lab palette (#111 bg, #e0e0e0 text, Inter + JetBrains Mono) but cleaner and tighter; legible on a phone (family may open the UI or shared links).

Layout — left sidebar (file tree) + main pane. Sidebar collapses to a ~48px icon rail via a toggle; collapsed/expanded state persists in localStorage (box_sidebar_collapsed) and animates smoothly.

Sidebar / tree — renders /api/list as a real tree: folders expand/collapse, nested indent, distinct icons for text/binary/folder. Each row: icon, name, and a share badge when shared. Hover reveals actions: share-toggle, rename, delete, copy-link (when shared) — updates optimistically, no full reload. Persistent affordances: upload (drag-drop anywhere + button; .zip offers “unpack as site”) and new-note.

Main pane, three states — text → live editor bound to /ws?path= with a saved/syncing indicator (edits from other tabs appear live); binary → metadata card (name, size, mtime, type) + open-in-new-tab (box.saah.as/<path>) / download / share; folder → opens box.saah.as/<folder>/ (its served site) in a new tab. Plus an empty/welcome state.

Share UX (the headline) — one obvious per-item toggle: off = private, on = anyone-with-link. When on, surface the copyable https://box.saah.as/<name> with a one-click copy button and a plain “anyone with this link can view” note; offer the unlisted (random-slug) option. Share state reflects immediately in both the pane and the tree badge.

Feel — fast, minimal chrome, keyboard-friendly where cheap; failed uploads/ops surface an error (toast or inline), never fail silently.

zsh CLI (box function in dotfiles/.zshrc)

Reads $LAB_KEY (add to ~/.env, already sourced at .zshrc:50 — same pattern as CF token). curl multipart through the gate:

box <file>...        upload private            → prints https://box.saah.as/<name>
box -s <file>...     upload + share            → same URL, now public
box -su <file>       share unlisted            → random-slug name

-s chains upload then POST /api/share. Errors if $LAB_KEY unset. Sits in the git-helpers block beside sy/gac.

API (box app)

MethodPathAuthAction
GET/gateUI
GET/api/listgateitems + shared flags (stat + public/ check)
POST/api/uploadgatemultipart write to files/; ?unpack=1 unzips
PUT/api/savegatewrite text file (editor autosave)
POST/api/sharegatesymlink public/<name>../files/<name>; ?slug=1 random name; returns URL
POST/api/unsharegateremove symlink
POST/api/rename, /api/deletegatemove/remove in files/ (+ cascade symlink)
WS/ws?path=<p>gateper-path live-edit room

All gate auth is nginx’s job (LAB_KEY); the app trusts it’s behind the gate, exactly as pad does today.

Security notes (fold into security.md)

  • Path traversal: every <name>/zip-entry sanitized to a single path component under files/ (extend sanitize_media_filename); reject .., absolute, symlink-escape. Zip unpack must refuse entries resolving outside files/<name>/ (zip-slip).
  • Sharing is “anyone with the link” — treat like an Immich share link (unguessable/slug name for sensitive content).
  • New anonymous surface: nginx try_files over data/public/ symlinks — static bytes only, no app code reachable anonymously (the app’s routes all sit behind the gate).
  • CSP sandbox on disk-served documents is what keeps shared HTML from acting on the app’s origin — don’t remove it casually (see above).

Rollout

  1. git mv services/pad services/box; rename pad.pybox.py, update systemd unit (pad.servicebox.service).
  2. Migrate data on Tower: pad/media/*box/data/files/; write global buffer to box/data/files/scratch.md; mkdir box/data/public.
  3. nginx: retire pad.saah.as block; add box.saah.as (app proxy + public-first/private-fallback try_files + CSP-sandbox map). Reload.
  4. DNS: add box.saah.as CNAME→home.saah.as (gray); Pi-hole /etc/hosts local record → 10.0.0.100; retire pad.saah.as.
  5. box zsh function + LAB_KEY in ~/.env (Mac). Test box/box -s.
  6. Gate self-tests (below); update services/README.md + security.md.

Decision — retire pad.saah.as clean (no redirect): fewer hostnames = smaller surface; the rename is a fresh start.

Gate self-test

curl -s -o /dev/null -w '%{http_code}' https://box.saah.as/               # closed (000) — gated
curl -s -H "X-Lab-Key: $LAB_KEY" https://box.saah.as/api/list             # 200 JSON
curl -s -o /dev/null -w '%{http_code}' https://box.saah.as/private-name   # closed (000) anonymously
# share a test .html, then anonymously:
curl -s -o /dev/null -w '%{http_code}' https://box.saah.as/<name>         # 200
curl -sI https://box.saah.as/<name> | grep -i content-security-policy     # sandbox allow-scripts

Shape of the diff

New files

  • services/nginx/box.confbox.saah.as server block: app proxy, try_files public→gated-private, CSP-sandbox suffix map (or fold into public.conf)
  • garden/private/notes/box-spec.md — this spec

Edited files (mostly renames)

  • services/box/box.py — was pad.py; add file tree, share toggle, per-path rooms, zip unpack, collapsible UI
  • services/box/README.md — was pad’s; data layout + share model
  • services/box/box.service — was pad.service
  • dotfiles/.zshrc — add box upload function
  • services/nginx/public.conf — remove pad.saah.as block
  • services/README.md, garden/private/notes/security.md — new surface + cookieless-origin rationale
  • DNS/Pi-hole — add box/pub, retire pad (manual, no repo diff)

Deleted

  • services/pad/ → becomes services/box/ via git mv (history preserved)