ADR-0114: One-Click Custom Website (.zip) Deploy
Status: Accepted
Date: 2026-07-07
Author: @johalputt
Relates to: ADR-0113, the business-site modes (VayuOS → Website)
Context
VayuPress can serve the blog at the root, or a built-in business-site template at the root. Operators also wanted to publish a fully custom website — hand-made or AI-generated — without a build step, terminal, or redeploy of the binary, while keeping the blog and existing post URLs intact.
The site's whole premise is a single sovereign Go binary with no Node/npm/build step. So "custom website" must mean: upload a static bundle and serve it — not run an arbitrary app.
Decision
Add a fourth site mode, custom: the operator uploads a .zip static site
in VayuOS → Website; VayuPress validates and serves it at the domain root. The
blog moves to /blog and posts keep their /slug URLs (reusing the
business_subpath routing from ADR-0113's release).
internal/customsiteowns validation, extraction, and serving:- Zip-slip proof: entry names are rejected if absolute or containing a
..segment, and the resolved path is confined under the target directory. - Regular files only (symlinks/specials rejected); extension allowlist of
static web asset types; per-file (25 MiB), total (50 MiB decompressed) and
file-count (3000) caps; a hard copy limit defeats zip bombs regardless of the
declared sizes;
index.htmlat the root is required. - Atomic activation: extraction happens in a staging dir and is swapped in only
on full success; the prior deployment is retained as
previous/for one-click rollback. - Serving is traversal-safe, sets
X-Content-Type-Options: nosniff, never lists directories, and only reads from the confinedcurrent/directory.
- Zip-slip proof: entry names are rejected if absolute or containing a
- Storage lives under the data root (sibling of
MEDIA_DIR), never underCACHE_DIR— the render cache is purged on content changes and would otherwise delete the uploaded site. - Serving integrates at two points only: the root handler serves the bundle's
index.html, and the shared 404 fallback serves any other bundle page/asset for a path that is not a real post or reserved route. Reserved dynamic paths (/blog,/api,/os,/media,/search,/tags, feeds, etc.) always win. - A downloadable AI build guide (served from the console) states exactly the rules the validator enforces, so an operator can hand it to any AI assistant and get a compliant bundle.
Consequences
- Positive: anyone can publish a bespoke site in one click, self-contained and offline-capable, with instant rollback — no build step, no new runtime, still a single binary. The blog and every existing post URL are untouched.
- Security: the upload is untrusted and handled with defence in depth (traversal,
symlink, type, size/count, zip-bomb, confinement, nosniff). Switching to
custommode is refused unless a bundle is actually deployed. - Trade-off: bundles are static only (no server code) by design; inline scripts in a custom bundle run under the bundle's own pages, so operators own the content they upload. Bundle storage is on the filesystem (not the SQLite backup set); it can be re-uploaded at any time and is covered by filesystem backups of the data root.