clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

release: v0.2.0 atomic publish policy

Dami committed 3b0087e
Add atomic password policy to push, introduce site management, refresh production UI/config and documentation, and bump the JSR workspace to 0.2.0.
commit
3b0087e94a68be164ac7351275b1fd2f05707849
parent
c143b2e564e912e135c837919afcf4936af7f996
author
Dami <dami.etoile@gmail.com>
16 files changed, +145 -63
M.gitignore3++
AAGENTS.md3++
MREADME.md25+++++++++++++------
Mcli/README.md12+++++++--
Mcli/commands/push.ts19+++++++++++++--
Mcli/commands/sites.ts9+++++--
Mcli/deno.json2+-
Mcli/lib/config.ts2+-
Mcli/main.ts25+++++++++++++------
Mshared/README.md8+++---
Mshared/deno.json2+-
Mshared/types.ts4++-
Mworker/src/api.ts39+++++++++++++++++++++---------
Mworker/src/db.ts37++++++++++++++++++++++-------
Mworker/src/serve.ts11+++-----
Mworker/wrangler.jsonc7+++--
M .gitignore +3 -0
A AGENTS.md +3 -0
M README.md +17 -8
@@ -2,10 +2,12 @@
22 33 **Push a directory of HTML from the terminal. Get a four-character URL back.**44 5-_A personal share tool. Deno CLI → Cloudflare Worker → R2 + D1. No daemon, no build step, no6-redeploys._5+_Deno CLI → Cloudflare Worker → R2 + D1. Zero-cost static site hosting with optional expiration6+and/or password protection._77 8-[![JSR](https://jsr.io/badges/@nzip/cli)](https://jsr.io/@nzip/cli) ·8+[![JSR](https://jsr.io/badges/@nzip/cli)](https://jsr.io/@nzip/cli)9+10+[github.com/FelineStateMachine/nzip](https://github.com/FelineStateMachine/nzip) ·911 [args.io/cat/nzip](https://args.io/cat/nzip)1012 1113 ```console
@@ -48,8 +50,9 @@ three target forms:
4850 shares answer `410 Gone`; a daily cron sweeps them and garbage-collects unreferenced content.4951 - **Revertible.** The last 10 pushes per site are kept. `nzip revert` repoints the address at any of5052 them, and the revert itself is recorded as a push.51-- **Password-protectable.** `nzip share work:demo --password …` gates the site behind an unlock form52- (PBKDF2 hashing, signed per-site cookie, 7 days).53+- **Password-protectable.** `nzip push ./demo work:demo --password …` publishes the content and54+ password policy atomically, then gates the site behind an unlock form (PBKDF2 hashing, signed55+ per-site cookie, 7 days). Use `nzip site` to change protection later.5356 - **Single-user by design.** One bearer token, stored as a Worker secret and in5457 `~/.config/nzip/config.json` (mode 0600).5558 - **Locates itself.** `nzip where <target>` prints the local directory this machine pushed a site
@@ -67,9 +70,9 @@ three target forms:
6770 nzip auth [--server URL] [--token T] authenticate and save config6871 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)6972 nzip vault ls | default <name> list vaults / set the default70-nzip push <dir|file> [target] [--ttl 14d|30d|forever]73+nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]7174 nzip download <target> [dir] [--overwrite] recover the current hosted bundle72-nzip share <target> [--ttl …] [--password PW | --no-password]75+nzip site <target> [--ttl …] [--password PW | --no-password]7376 nzip ls [vault] list sites7477 nzip where <target> print the local dir this machine pushed from7578 nzip rm <target> [--yes] delete a site
@@ -77,6 +80,11 @@ nzip status server + vault overview
7780 nzip revert <target> [--to N] [--list] repoint to a previous push7881 ```7982 83+Password and TTL are committed with the content. On a new site, omitting `--password` creates an84+unprotected site; on an existing target, omission preserves its current password. Pass85+`--no-password` to clear protection explicitly. The former `nzip share` command remains available86+as a compatibility alias for `nzip site`.87+8088 Pushing a single `page.html` stores it as the site's `index.html`. Directory pushes skip dotfiles8189 and `node_modules`, and honor a `.nzipignore` (one glob per line). Single-file sites serve directly8290 at the bare address (`/2a3f`); multi-file bundles redirect to `/2a3f/` so relative asset URLs
@@ -104,7 +112,8 @@ A push is a stateless three-step protocol, and the manifest itself is the state:
104112 2. `PUT /api/blob/{sha256}`: upload only those, one per request, 6 at a time; the server re-hashes105113 and rejects mismatches106114 3. `POST /api/push/commit`: the server re-verifies every blob exists, then commits atomically,107- resolving or allocating the address, repointing the site, and appending history115+ resolving or allocating the address, applying TTL/password policy, repointing the site, and116+ appending history108117 109118 Serving is one D1 read (address → manifest, expiry, password) and two R2 reads, with `ETag`110119 revalidation and a 60-second cache. Metadata lives in three tables:
M cli/README.md +9 -3
@@ -26,7 +26,8 @@ deno run -A jsr:@nzip/cli --help
2626 ```sh2727 nzip auth --server https://share.example.com # authenticate (prompts for the token)2828 nzip vault add work # register a named vault29-nzip push ./site work:demo --ttl 30d # → https://share.example.com/12d829+nzip push ./site work:demo --ttl 30d --password secret30+ # → https://share.example.com/12d83031 ```3132 3233 Config is saved to `~/.config/nzip/config.json` (mode 0600), so later commands just work. On a
@@ -39,9 +40,9 @@ and site is already there.
3940 nzip auth [--server URL] [--token T] authenticate and save config4041 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)4142 nzip vault ls | default <name> list vaults / set the default42-nzip push <dir|file> [target] [--ttl 14d|30d|forever]43+nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]4344 nzip download <target> [dir] [--overwrite]44-nzip share <target> [--ttl …] [--password PW | --no-password]45+nzip site <target> [--ttl …] [--password PW | --no-password]4546 nzip ls [vault] list sites4647 nzip where <target> print the local dir this machine pushed from4748 nzip rm <target> [--yes] delete a site
@@ -49,6 +50,11 @@ nzip status server + vault overview
4950 nzip revert <target> [--to N] [--list] repoint to a previous push5051 ```5152 53+Password and TTL are committed with the content. On a new site, omitting `--password` creates an54+unprotected site; on an existing target, omission preserves its current password. Pass55+`--no-password` to clear protection explicitly. The former `nzip share` command remains available as56+a compatibility alias for `nzip site`.57+5258 `nzip download work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty5359 directory. It uses the configured bearer token, verifies file hashes, and never exposes source via5460 the public page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were
M cli/commands/push.ts +17 -2
M cli/commands/sites.ts +6 -3
M cli/deno.json +1 -1
M cli/lib/config.ts +1 -1
M cli/main.ts +17 -8
@@ -2,7 +2,7 @@
22 /**33 * `nzip` — push a directory of HTML from the terminal and get a four-character44 * URL back. This is the CLI entrypoint; it parses argv and dispatches to the5- * command handlers (`auth`, `vault`, `push`, `share`, `ls`, `where`, `rm`,5+ * command handlers (`auth`, `vault`, `push`, `site`, `ls`, `where`, `rm`,66 * `status`, `revert`).77 *88 * Install and run it as a command rather than importing it:
@@ -21,7 +21,7 @@ import { parseArgs } from "@std/cli/parse-args";
2121 import { cmdAuth } from "./commands/auth.ts";2222 import { cmdPush } from "./commands/push.ts";2323 import { cmdDownload } from "./commands/download.ts";24-import { cmdLs, cmdRevert, cmdRm, cmdShare, cmdStatus } from "./commands/sites.ts";24+import { cmdLs, cmdRevert, cmdRm, cmdSite, cmdStatus } from "./commands/sites.ts";2525 import { cmdVault } from "./commands/vault.ts";2626 import { cmdWhere } from "./commands/where.ts";2727 import { requireConfig } from "./lib/config.ts";
@@ -34,10 +34,11 @@ usage:
3434 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0-0xf)3535 nzip vault ls list vaults3636 nzip vault default <name> set default vault37- nzip push <dir|file> [target] [--ttl 14d|30d|forever]37+ nzip push <dir|file> [target] [--ttl ...] [--password PW | --no-password]3838 nzip download <target> [dir] [--overwrite]39- nzip share <target> [--ttl ...] [--password PW | --no-password]40- resolve + print url; set ttl/password39+ nzip site <target> [--ttl ...] [--password PW | --no-password]40+ inspect or update ttl/password41+ nzip share <target> [...] deprecated alias for nzip site4142 nzip ls [vault] list sites4243 nzip where <target> print the local dir this machine pushed from4344 nzip rm <target> [--yes] delete a site
@@ -75,11 +76,19 @@ async function main(): Promise<void> {
7576 7677 switch (command) {7778 case "push":78- return await cmdPush(config, rest[0], rest[1], args.ttl);79+ return await cmdPush(80+ config,81+ rest[0],82+ rest[1],83+ args.ttl,84+ args.password,85+ args["no-password"],86+ );7987 case "download":8088 return await cmdDownload(config, rest[0], rest[1], args.overwrite);81- case "share":82- return await cmdShare(config, rest[0], args.ttl, args.password, args["no-password"]);89+ case "site":90+ case "share": // compatibility alias for the former site-management command91+ return await cmdSite(config, rest[0], args.ttl, args.password, args["no-password"]);8392 case "ls":8493 return await cmdLs(config, rest[0]);8594 case "where":
M shared/README.md +4 -4
M shared/deno.json +1 -1
M shared/types.ts +3 -1
M worker/src/api.ts +27 -12
@@ -53,6 +53,14 @@ function ttlToExpiry(ttl: number | "forever" | undefined): number | null {
5353 return Math.floor(Date.now() / 1000) + Math.round(days * 86400);5454 }5555 56+async function passwordHashFor(password: unknown): Promise<string | null | undefined> {57+ if (password === undefined || password === null) return password;58+ if (typeof password !== "string" || password.length < 4) {59+ throw new Error("password must be at least 4 characters");60+ }61+ return await hashPassword(password);62+}63+5664 /** Which blob hashes referenced by the manifest are absent from R2? */5765 async function missingBlobs(env: Env, manifest: Manifest): Promise<string[]> {5866 const hashes = [...new Set(Object.values(manifest.files).map((f) => f.h))];
@@ -85,6 +93,7 @@ async function handleBlobPut(req: Request, env: Env, hash: string): Promise<Resp
8593 8694 async function handleCommit(req: Request, env: Env): Promise<Response> {8795 const body = await req.json<CommitRequest>();96+ const passwordHash = await passwordHashFor(body.password);8897 const bytes = canonicalManifestBytes(body.manifest);8998 const hash = await sha256hex(bytes);9099
@@ -137,6 +146,7 @@ async function handleCommit(req: Request, env: Env): Promise<Response> {
137146 alias,138147 manifestHash: hash,139148 expiresAt,149+ passwordHash,140150 isNew,141151 });142152
@@ -167,7 +177,7 @@ async function handleStatus(env: Env): Promise<Response> {
167177 const soon = Math.floor(Date.now() / 1000) + 48 * 3600;168178 return json<StatusResponse>({169179 ok: true,170- version: "0.1.0",180+ version: "0.2.0",171181 vaults,172182 siteCount: sites.length,173183 expiringSoon: sites.filter((s) => s.expires_at !== null && s.expires_at < soon).length,
@@ -275,18 +285,23 @@ export async function api(req: Request, env: Env, url: URL): Promise<Response> {
275285 if (parts.length === 3 && method === "PATCH") {276286 const body = await req.json<PatchSiteRequest>();277287 const now = Math.floor(Date.now() / 1000);278- if (body.ttl !== undefined) {279- const expiresAt = ttlToExpiry(body.ttl);280- await env.DB.prepare("UPDATE sites SET expires_at = ?, updated_at = ? WHERE address = ?")281- .bind(expiresAt, now, site.address).run();288+ const expiresAt = body.ttl === undefined ? undefined : ttlToExpiry(body.ttl);289+ const passwordHash = await passwordHashFor(body.password);290+ const updates: string[] = [];291+ const values: (string | number | null)[] = [];292+ if (expiresAt !== undefined) {293+ updates.push("expires_at = ?");294+ values.push(expiresAt);295+ }296+ if (passwordHash !== undefined) {297+ updates.push("password_hash = ?");298+ values.push(passwordHash);282299 }283- if (body.password !== undefined) {284- if (body.password !== null && body.password.length < 4) {285- return err("password must be at least 4 characters", 400);286- }287- const hash = body.password === null ? null : await hashPassword(body.password);288- await env.DB.prepare("UPDATE sites SET password_hash = ?, updated_at = ? WHERE address = ?")289- .bind(hash, now, site.address).run();300+ if (updates.length > 0) {301+ updates.push("updated_at = ?");302+ values.push(now, site.address);303+ await env.DB.prepare(`UPDATE sites SET ${updates.join(", ")} WHERE address = ?`)304+ .bind(...values).run();290305 }291306 const updated = await getSiteByAddress(env, site.address);292307 return json(await siteRowToDetail(env, updated!));
M worker/src/db.ts +28 -9
@@ -76,6 +76,8 @@ export interface CommitParams {
7676 alias: string | null;7777 manifestHash: string;7878 expiresAt: number | null;79+ /** undefined preserves an existing password; null explicitly clears it */80+ passwordHash?: string | null;7981 isNew: boolean;8082 note?: string;8183 }
@@ -87,16 +89,33 @@ export async function commitSite(env: Env, p: CommitParams): Promise<number> {
8789 .bind(p.address).first<{ s: number }>();8890 const seq = (seqRow?.s ?? 0) + 1;8991 92+ const siteCommit = p.isNew93+ ? env.DB.prepare(94+ `INSERT INTO sites (95+ address, vault_slot, alias, current_manifest, created_at, updated_at, expires_at, password_hash96+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,97+ ).bind(98+ p.address,99+ p.vaultSlot,100+ p.alias,101+ p.manifestHash,102+ now,103+ now,104+ p.expiresAt,105+ p.passwordHash ?? null,106+ )107+ : p.passwordHash === undefined108+ ? env.DB.prepare(109+ `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?, alias = COALESCE(?, alias)110+ WHERE address = ?`,111+ ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.address)112+ : env.DB.prepare(113+ `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?,114+ alias = COALESCE(?, alias), password_hash = ? WHERE address = ?`,115+ ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.passwordHash, p.address);116+90117 const statements = [91- p.isNew92- ? env.DB.prepare(93- `INSERT INTO sites (address, vault_slot, alias, current_manifest, created_at, updated_at, expires_at)94- VALUES (?, ?, ?, ?, ?, ?, ?)`,95- ).bind(p.address, p.vaultSlot, p.alias, p.manifestHash, now, now, p.expiresAt)96- : env.DB.prepare(97- `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?, alias = COALESCE(?, alias)98- WHERE address = ?`,99- ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.address),118+ siteCommit,100119 env.DB.prepare(101120 "INSERT INTO pushes (address, seq, manifest_hash, pushed_at, note) VALUES (?, ?, ?, ?, ?)",102121 ).bind(p.address, seq, p.manifestHash, now, p.note ?? null),
M worker/src/serve.ts +4 -7
@@ -19,12 +19,9 @@ const escapeHtml = (s: string): string =>
1919 // hostname with the nzip wordmark; the coral z is the product's small visual mark.2020 function landingPage(env: Env): string {2121 const host = new URL(env.PUBLIC_BASE).host;22- const dot = host.indexOf(".");23- const head = escapeHtml(dot === -1 ? host : host.slice(0, dot));24- const tail = dot === -1 ? "" : escapeHtml(host.slice(dot));2522 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(host)}</title>2623 <style>body{background:#1e1810;color:#e7dbc5;font-family:ui-monospace,monospace;margin:0;min-height:100dvh;display:flex;flex-direction:column}main{flex:1;display:grid;place-items:center;padding:24px;text-align:center}.landing{display:grid;gap:24px;justify-items:center}.wordmark{color:#f2e8d4;font-size:clamp(40px,10vw,72px);font-weight:700;letter-spacing:-.18em;line-height:.8}.wordmark .z{color:#ff6b4a}.host{font-size:clamp(14px,2vw,18px);letter-spacing:-.06em}.host b{color:#d99a5b}footer{text-align:center;padding:14px 16px calc(14px + env(safe-area-inset-bottom))}a{color:#6b6355;font-size:12px;text-decoration:underline}a:hover{color:#d99a5b}</style>27-<main><div class="landing"><div class="wordmark" aria-label="nzip">n<span class="z">z</span>ip</div><div class="host">${head}${tail ? `<b>${tail}</b>` : ""}</div></div></main><footer><a href="https://args.io/cat/nzip">args</a></footer>`;24+<main><div class="landing"><div class="wordmark" aria-label="nzip">n<span class="z">z</span>ip</div></div></main><footer><a href="https://args.io/cat/nzip">args</a></footer>`;2825 }2926 3027 function htmlResponse(body: string, status: number, headers: HeadersInit = {}): Response {
@@ -35,9 +32,9 @@ function htmlResponse(body: string, status: number, headers: HeadersInit = {}):
3532 }3633 3734 function unlockForm(address: string, error?: string): string {38- return `<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>unlock — nzip</title>39-<style>body{background:#121110;color:#d6cfc2;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}form{text-align:center}b{color:#ffb347;font-size:24px;display:block;margin-bottom:16px}input{background:#1a1815;border:1px solid #2e2a24;color:#d6cfc2;padding:10px 14px;font:inherit;outline:none}input:focus{border-color:#ffb347}button{background:#ffb347;border:0;color:#121110;padding:10px 18px;font:inherit;font-weight:700;cursor:pointer}.e{color:#f7768e;margin-top:12px;font-size:13px}</style>40-<form method="post" action="/${address}/__unlock"><b>&#128274; ${address}</b><input type="password" name="password" placeholder="password" autofocus autocomplete="current-password"><button>unlock</button>${35+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="robots" content="noindex"><title>unlock — nzip</title>36+<style>*{box-sizing:border-box}body{background:#121110;color:#d6cfc2;font-family:ui-monospace,monospace;display:grid;place-items:center;min-height:100vh;min-height:100dvh;margin:0;padding:max(24px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}form{width:min(100%,420px);text-align:center}b{color:#ffb347;font-size:24px;display:block;margin-bottom:20px}.field{display:flex;width:100%}input{background:#1a1815;border:1px solid #2e2a24;color:#d6cfc2;min-width:0;flex:1;padding:12px 14px;font:inherit;font-size:16px;line-height:20px;outline:none;border-radius:0}input:focus{border-color:#ffb347}button{background:#ffb347;border:0;color:#121110;min-height:46px;padding:12px 18px;font:inherit;font-weight:700;cursor:pointer;border-radius:0}.e{color:#f7768e;margin-top:12px;font-size:13px}@media(max-width:360px){.field{display:grid;gap:10px}button{width:100%}}</style>37+<form method="post" action="/${address}/__unlock"><b>&#128274; ${address}</b><div class="field"><input type="password" name="password" aria-label="password" placeholder="password" autofocus autocomplete="current-password"><button>unlock</button></div>${4138 error ? `<div class="e">${error}</div>` : ""4239 }</form>`;4340 }
M worker/wrangler.jsonc +4 -3