box — lightweight Drive over nginx
Goal: Rename pad → box 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)
| Method | Path | Auth | Action |
|---|---|---|---|
| GET | / | gate | UI |
| GET | /api/list | gate | items + shared flags (stat + public/ check) |
| POST | /api/upload | gate | multipart write to files/; ?unpack=1 unzips |
| PUT | /api/save | gate | write text file (editor autosave) |
| POST | /api/share | gate | symlink public/<name>→../files/<name>; ?slug=1 random name; returns URL |
| POST | /api/unshare | gate | remove symlink |
| POST | /api/rename, /api/delete | gate | move/remove in files/ (+ cascade symlink) |
| WS | /ws?path=<p> | gate | per-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 underfiles/(extendsanitize_media_filename); reject.., absolute, symlink-escape. Zip unpack must refuse entries resolving outsidefiles/<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_filesoverdata/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
git mv services/pad services/box; renamepad.py→box.py, update systemd unit (pad.service→box.service).- Migrate data on Tower:
pad/media/*→box/data/files/; write global buffer tobox/data/files/scratch.md;mkdir box/data/public. - nginx: retire
pad.saah.asblock; addbox.saah.as(app proxy + public-first/private-fallback try_files + CSP-sandbox map). Reload. - DNS: add
box.saah.asCNAME→home.saah.as (gray); Pi-hole/etc/hostslocal record → 10.0.0.100; retirepad.saah.as. boxzsh function +LAB_KEYin~/.env(Mac). Testbox/box -s.- 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-scriptsShape of the diff
New files
services/nginx/box.conf—box.saah.asserver 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— waspad.py; add file tree, share toggle, per-path rooms, zip unpack, collapsible UIservices/box/README.md— was pad’s; data layout + share modelservices/box/box.service— waspad.servicedotfiles/.zshrc— addboxupload functionservices/nginx/public.conf— removepad.saah.asblockservices/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/→ becomesservices/box/viagit mv(history preserved)