box — build requirements
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. While here, consolidate all services to run from the monorepo.
Design rationale (why one namespace, why CSP sandbox, why nginx-serves): box-spec.md. This doc is the build contract — requirements and acceptance, not step-by-step. Execute on branch box; the Tower cutover (services, nginx, DNS) lands after the app + configs are reviewed.
Invariants (must always hold — these are the security spec)
- Code in the synced repo, data/venvs outside it. box runs from
/home/ns/saah.as/services/box(mutagen-synced, git-tracked); its data isBOX_DATA=/home/ns/box-data(never synced, never git). No uploads ever land in the repo tree. - Every filesystem path derives from one chokepoint that resolves under its root or raises — no file op takes a caller string without it. Traversal (
../), absolute paths, symlink-escapes, and reserved names (api,ws,gate) are rejected. Zip members that escape their dest are rejected (zip-slip). - Anonymous requests reach only shared items. An unshared private path must be unreachable without the gate — verified by an explicit acceptance test (an unshared file returns a closed connection anonymously). This is the one test that guards the whole public-first/gated-private design.
- Shared documents cannot act on the app origin. Disk-served
.html/.htm/.svg/.xmlcarryContent-Security-Policy: sandbox allow-scripts(opaque origin → no gate cookie, no readable API responses). The app’s own proxied UI is exempt. - Share state is filesystem state —
PUBLIC/<name>is a symlink intoFILESiff the item is shared. Every mutation (share, unshare, rename, delete) preserves this; deleting or renaming a shared item cascades to its symlink.
A. Service consolidation (outcomes)
- exo: decommissioned — unit stopped, disabled, removed; its ~8.9 G runtime (
~/services/exo) deleted;services/exo/removed from the repo. (User: “haven’t been using exo, pull it down.”) - camera-mux: runs from
~/saah.as/services/camera(repointWorkingDirectory;camera_mux.pyhas no filesystem state, so a pure repoint). Stream still serves on:8100. - box: runs from
~/saah.as/services/boxwithBOX_DATA=/home/ns/box-data. - stale tree gone: after the three above, no systemd unit references
/home/ns/services; delete~/servicesentirely. Remaining services already run elsewhere (immich =~/immichdocker; minecraft/boinc = own units).
B. box app requirements
Single-file FastAPI app (services/box/box.py), uv inline deps unchanged from pad (fastapi, uvicorn, websockets, python-multipart). No new pip deps. Add stdlib secrets, shutil, zipfile.
Data model. DATA = Path($BOX_DATA or ./data); FILES = DATA/files (all items, private default), PUBLIC = DATA/public (share symlinks). Both created at startup. The app never serves file bytes — no /media mount; the browser reaches files at box.saah.as/<path> (nginx).
Security core (the invariant-2/-5 machinery). These reference implementations are the requirement — an equivalent that passes the same adversarial cases is fine:
RESERVED = {"api", "ws", "gate"}
def safe_path(root: Path, rel: str, reserved: bool = False) -> Path:
root = root.resolve()
rel = rel.strip().lstrip("/")
if not rel:
raise ValueError("empty path")
if reserved and rel.split("/", 1)[0] in RESERVED:
raise ValueError("reserved name")
p = (root / rel).resolve()
if p != root and root not in p.parents: # catches ../ and symlink escapes
raise ValueError("path escapes root")
return p
def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
dest = dest.resolve(); dest.mkdir(parents=True, exist_ok=True)
for m in zf.namelist():
t = (dest / m).resolve()
if t != dest and dest not in t.parents:
raise ValueError(f"zip-slip: {m}")
zf.extractall(dest)Adversarial tests are required (in services/box/test_box.py, run with uv run --with pytest pytest): safe_path rejects ../etc/passwd, /etc/passwd, and a symlink-into-/etc; reserved names rejected; safe_extract refuses a ../evil.txt member; delete of a shared file leaves no symlink; rename rejects an escaping target. Happy paths pass. These tests are the definition of done for the security core.
API contract (all behind the gate at nginx; the app trusts it’s gated, as pad does):
| Endpoint | Behaviour |
|---|---|
GET / | control-plane UI |
GET /api/list | recursive listing of FILES: [{path, size, mtime, dir, shared}]; shared = a PUBLIC symlink resolves to it |
POST /api/upload (multipart file, ?dir=, ?unpack=1) | write under FILES via safe_path(..., reserved=True); .zip + unpack → safe_extract into FILES/<stem>/; returns {path} |
POST /api/share ({path, slug?}) | symlink PUBLIC/<name> → FILES/<path>; name = last component, or token_hex(4)+"-"+base if slug; returns {url} |
POST /api/unshare ({path}) | remove the PUBLIC symlink resolving to FILES/<path> |
POST /api/rename ({path, to}) | move within FILES; re-point an existing share |
POST /api/delete ({path}) | unshare then remove under FILES (rmtree for dirs) |
WS /ws?path=<rel> | per-path live-edit room: on connect send disk text; each message writes the file (debounced) and broadcasts to peers in that room; drop stale sockets; drop empty rooms |
Live edit / notes. No separate “note” type — a text file is a note. Text (.md/.txt/.html/.css/.js/.json/…) opens in the editor and live-syncs per-path (invariant: the single global pad buffer generalizes to one room per path). pad’s in-memory buffer starts fresh as scratch.md. Binary shows metadata + actions. Folders serve at box.saah.as/<folder>/ with nginx index index.html.
C. UI requirements
Single-page app in the HTML string. Keep pad’s dark/mono lab aesthetic (near-black #111 bg, #e0e0e0 text, Inter + JetBrains Mono) but cleaner and tighter — this is a redesign, not a reskin. Legible on a phone (family may open shared links / the UI).
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/listas a real tree: folders expand/collapse, nesting indented; text vs binary vs folder each get a distinct type icon. - Each row: icon, name, and a share indicator (a clear dot/badge when shared).
- Row hover reveals actions: share-toggle, rename, delete, copy-link (when shared). Actions update the tree optimistically — no full reload.
- Persistent affordances: upload (drag-drop anywhere in the window + an explicit button;
.zipoffers an “unpack as site” option) and new note (creates an empty text file and opens it).
Main pane — three states:
- Text → live editor bound to
/ws?path=, with a subtle saved/syncing indicator. Edits from other tabs appear live. - Binary → metadata card (name, size, mtime, type) + actions: open in new tab (
box.saah.as/<path>), download, share. - Folder → open
box.saah.as/<folder>/(its served site/index) in a new tab. - Plus an empty/welcome state when nothing is selected.
Share UX (the headline feature). A single obvious toggle per item: off = private, on = anyone with the link. When on, surface the exact public URL https://box.saah.as/<name> with a one-click copy button and a plain-language “anyone with this link can view” note; offer the unlisted (random-slug) option. Sharing state is reflected immediately in both the pane and the tree badge. Sensitive-content caveat is the user’s call (capability URL, like an Immich share link).
Feel. Fast, minimal chrome, keyboard-friendly where cheap; clear error surfacing on failed upload/op (a toast or inline message, not a silent failure).
D. nginx — box.saah.as (in services/nginx/public.conf, retiring the pad.saah.as block)
The routing is invariants 3 + 4. Requirements: app UI//api///ws are gated and proxied to 127.0.0.1:8000; all other paths try the anonymous public tree first, then fall back to a gated private tree — same URL either way; disk-served documents get the CSP sandbox header. Reference block (reuses the existing $lab_ok map and /gate/<key> cookie-setter; add a $box_csp map):
map $uri $box_csp { default ""; "~\.(html?|svg|xml)$" "sandbox allow-scripts"; }
server {
listen 443 ssl http2;
server_name box.saah.as;
ssl_certificate /etc/letsencrypt/live/saah.as/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/saah.as/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location = /gate/__LAB_KEY__ {
add_header Set-Cookie "lab_key=__LAB_KEY__; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000";
return 302 /;
}
location = / { if ($lab_ok = 0) { return 444; } proxy_pass http://127.0.0.1:8000; include /etc/nginx/snippets/box-proxy.conf; }
location /api/ { if ($lab_ok = 0) { return 444; } proxy_pass http://127.0.0.1:8000; include /etc/nginx/snippets/box-proxy.conf; }
location = /ws { if ($lab_ok = 0) { return 444; } proxy_pass http://127.0.0.1:8000; include /etc/nginx/snippets/box-proxy.conf; }
location / {
root /home/ns/box-data/public; index index.html;
add_header Content-Security-Policy $box_csp always;
try_files $uri $uri/ @private;
}
location @private {
if ($lab_ok = 0) { return 444; }
root /home/ns/box-data/files;
add_header Content-Security-Policy $box_csp always;
try_files $uri $uri/ =404;
}
}services/nginx/box-proxy.conf (→ /etc/nginx/snippets/): the shared proxy headers (HTTP/1.1, Upgrade/Connection for the WS, Host, X-Forwarded-*, client_max_body_size 0).
E. Deploy & cutover (outcomes + the gotchas that bite)
Sequence so nothing locks out and downtime is minimal: build + test the app and configs on the branch → merge → mutagen carries code to ~/saah.as/services/box → migrate data, start box.service, deploy nginx, flip DNS.
- Data migration:
~/services/pad/media/*→~/box-data/files/;mkdir ~/box-data/public; seed emptyscratch.md. (Runtime data — do this before deleting~/services.) - nginx deploy regenerates
/etc/nginx/sites-available/publicfrom the synced repo copy with the key substituted (sed s/__LAB_KEY__/$KEY/g), recovering the existing 64-hex key from the current live config — the repo keeps placeholders, Tower holds the real key (same pattern as the drop-cloudflared/share work). box reuses the existing gate key; no new secret. - DNS (user): Cloudflare
box.saah.asCNAME →home.saah.as(gray); deletepad.saah.as. Pi-hole/etc/hosts: add10.0.0.100 box.saah.as, droppad.saah.as, thenpihole reloaddns(v6 — notrestartdns). - Cutover: pad stops before box starts (brief pad-URL downtime, acceptable). box.service must be
activebefore nginx points at it.
F. CLI — box in dotfiles/.zshrc
A shell function in the git-helpers block, reading $LAB_KEY from ~/.env (already sourced; same pattern as the CF token — user adds the 64-hex value). Contract:
box <file>...→ upload private, printhttps://box.saah.as/<name>.box -s <file>...→ upload then share, print the public URL.-u→ unlisted (random slug). Errors clearly if$LAB_KEYis unset. Implementation: multipartPOST /api/uploadwithX-Lab-Key, thenPOST /api/sharewhen-s.
G. Docs
services/box/README.md: data layout, share model, CSP-sandbox note, CLI usage.services/README.md: go-links pad→box; all services run from the monorepo, data external; exo removed.security.md: box in the surface map — gated app + public-first static, the CSP-sandbox same-origin isolation, box CLI carries LAB_KEY,pad.saah.asretired.index.md: box row → shipped.
Acceptance (the security gate)
From the Mac, off the gate cookie, after cutover:
curl -s -o /dev/null -w '%{http_code}\n' https://box.saah.as/ # 000 gated app
curl -s -H "X-Lab-Key: $LAB_KEY" https://box.saah.as/api/list | head -c 80 # JSON
echo '<h1>hi</h1>' > /tmp/t.html && box -s /tmp/t.html # prints public URL
curl -s -o /dev/null -w '%{http_code}\n' https://box.saah.as/t.html # 200 shared, anonymous
curl -sI https://box.saah.as/t.html | grep -i content-security-policy # sandbox allow-scripts
curl -s -o /dev/null -w '%{http_code}\n' https://box.saah.as/scratch.md # 000 private, unshared ← MUST be closedThe last line is the load-bearing one (invariant 3): if an unshared file is reachable anonymously, the design is leaking — stop and fix before anything else. Plus: pytest green (security core), collapse persists across reload, upload appears in the tree, share badge toggles, editor live-syncs across two tabs.
Shape of the diff
New: services/nginx/box-proxy.conf; services/box/test_box.py; services/box/box.service; services/box/.gitignore.
Renamed/rewritten: services/pad/ → services/box/ (git mv, history kept); box.py (was pad.py) gains the file tree, share model, per-path rooms, zip unpack, redesigned UI; services/box/README.md.
Edited: services/nginx/public.conf (box block, retire pad); services/camera/camera-mux.service (monorepo path); dotfiles/.zshrc (box CLI); services/README.md, garden/private/notes/security.md, index.md.
Deleted: services/exo/; (Tower) ~/services, exo unit + runtime.
Manual: DNS (box add, pad delete), Pi-hole record, LAB_KEY→~/.env.