cat / nz
clone

push a directory, get a url: sh + one Cloudflare Worker + KV

nz: push a directory, get a url

cat committed bc588cf
A small take on nzip: a POSIX sh client (tar + curl), one plain-JS
Cloudflare Worker, and one KV entry per site. Push, update, ttl and
password, nothing else. Includes an end-to-end test against wrangler dev
and a homepage with an architecture overview (forge site "home").
commit
bc588cfe22f2071ccb1d86a9d45abf7c746a3a7f
author
cat <admin@dev.site>
kind
feat risk score 0.2 of 3, Jev's estimate
9 files changed, +614 -0
A.forge/sites2+
A.gitignore3+
ALICENSE12++
AREADME.md81++++++++++++++
Anz95++++++++++++++++
Asite/index.html177++++++++++++++++++++++++++++++
Atest.sh60++++++++++
Aworker.js170+++++++++++++++++++++++++++++
Awrangler.jsonc14++
A .forge/sites +2 -0
A .gitignore +3 -0
A LICENSE +12 -0
A README.md +81 -0
@@ -0,0 +1,81 @@
1+# nz2+3+Push a directory of HTML, get a url back. A small, readable take on [nzip](https://github.com/FelineStateMachine/nzip).4+5+```console6+$ nz push ./report --password meow7+expires 2026-11-17T19:40:02.000Z, password protected8+https://k3x9q.example.com/9+```10+11+## Minimalism12+13+nz is three files you can read in one sitting:14+15+| file | what |16+| ------------- | ------------------------------------------------------ |17+| `worker.js` | Cloudflare Worker, plain JS, no dependencies, no build |18+| `nz` | POSIX `sh` CLI: `tar` + `curl`, nothing to install |19+| `test.sh` | end-to-end test against `wrangler dev` |20+21+A site is **one KV entry**: the value is a tar of the directory, and the metadata is22+`{ exp, pw }`. KV expiration deletes it, so there is no database, blob store, cron, or GC.23+24+nz does push, update, ttl, and password. It does not do vaults, aliases, history/revert,25+dedup, notifications, feedback, apps, listing, or deletion (let it expire, or `--ttl 1m`).26+If you need those, use nzip.27+28+## Install29+30+```sh31+wrangler kv namespace create SITES # paste the id into wrangler.jsonc32+# set DOMAIN + routes in wrangler.jsonc, add a proxied DNS record: * AAAA 100::33+wrangler secret put TOKEN34+wrangler deploy35+36+ln -s "$PWD/nz" ~/bin/nz37+mkdir -p ~/.config/nz && printf 'NZ_SERVER=https://example.com\nNZ_TOKEN=...\n' > ~/.config/nz/config38+```39+40+## CLI41+42+```text43+nz push <dir|file> [--ttl 14d|never] [--password PW] [--new]44+nz update <id> [dir|file] [--ttl 14d|never] [--password PW | --no-password]45+```46+47+- `push` updates the site last pushed from the same path (`~/.config/nz/sites`, one48+ `id<TAB>path` per line), or makes a new one if there is none, it expired, or `--new`.49+- `update` targets an id. Without a path it only changes ttl/password.50+- A single file is published as `index.html`. Dotfiles and `node_modules` are skipped.51+- TTL: `30m`, `12h`, `14d`, `2w`, `never`. Default `DEFAULT_TTL` (14d). Omitted on update = kept.52+- The url goes to stdout and details to stderr, so `open "$(nz push .)"` works.53+54+## API55+56+The CLI is a thin wrapper around this. Everything lives on the apex host:57+58+```sh59+tar --format=ustar -cf - -C site . |60+ curl -H "authorization: Bearer $TOKEN" -H 'nz-ttl: 7d' -H 'nz-password: meow' \61+ --data-binary @- https://example.com/ # POST: new site -> 20162+curl -X PUT ... --data-binary @site.tar https://example.com/k3x9q # replace content63+curl -X PUT -H "authorization: ..." -H 'nz-password;' https://example.com/k3x9q # policy only64+```65+66+The response is the url on line 1 and a details line on line 2. `nz-password;` (empty) removes the67+password. Omitted headers keep their current value on `PUT`.68+69+Sites live at `https://<id>.DOMAIN/`, so each one is its own browser origin. Password-protected70+sites show a form. Unlocking sets a host-only `__Host-nz` cookie that lasts 7 days and is signed71+over the password verifier, so changing the password logs everyone out. Unlock attempts are72+rate-limited to 5 per minute per ip and site.73+74+## Caveats75+76+- KV is eventually consistent: an update can take up to ~60s to show up in other regions.77+- KV free tier allows 1,000 writes/day (one per push) and 25 MiB per site.78+79+## License80+81+0BSD
A nz +95 -0
@@ -0,0 +1,95 @@
1+#!/bin/sh2+# nz: push a directory (or one html file) to your nz worker, print its url.3+#4+# nz push <dir|file> [--ttl 14d|never] [--password PW] [--new]5+# nz update <id> [dir|file] [--ttl 14d|never] [--password PW | --no-password]6+#7+# push updates the site last pushed from the same path (kept in $NZ_HOME/sites)8+# unless --new is given or that site has expired. update targets an id; with no9+# path it only changes ttl/password. The url goes to stdout, details to stderr.10+# Config: NZ_SERVER and NZ_TOKEN, from the environment or $NZ_HOME/config.11+set -eu12+13+NZ_HOME=${NZ_HOME:-$HOME/.config/nz}14+die() { echo "nz: $*" >&2; exit 1; }15+usage() { sed -n '4,5s/^# *//p' "$0" >&2; exit 2; }16+17+[ $# -gt 0 ] || usage18+cmd=$1; shift19+case $cmd in push | update) ;; *) usage ;; esac20+[ -f "$NZ_HOME/config" ] && . "$NZ_HOME/config"21+[ -n "${NZ_SERVER:-}" ] && [ -n "${NZ_TOKEN:-}" ] || die "set NZ_SERVER and NZ_TOKEN in $NZ_HOME/config"22+23+tmp=$(mktemp -d)24+trap 'rm -rf "$tmp"' EXIT25+# headers live in a file so the token and password stay out of `ps`26+printf 'authorization: Bearer %s\n' "$NZ_TOKEN" > "$tmp/headers"27+28+new='' a='' b=''29+while [ $# -gt 0 ]; do30+ case $1 in31+ --ttl) [ $# -ge 2 ] || usage; printf 'nz-ttl: %s\n' "$2" >> "$tmp/headers"; shift ;;32+ --password) [ $# -ge 2 ] || usage; printf 'nz-password: %s\n' "$2" >> "$tmp/headers"; shift ;;33+ --no-password) echo 'nz-password;' >> "$tmp/headers" ;;34+ --new) new=1 ;;35+ -*) usage ;;36+ *) if [ -z "$a" ]; then a=$1; elif [ -z "$b" ]; then b=$1; else usage; fi ;;37+ esac38+ shift39+done40+41+# pack <path>: ustar of a directory (no dotfiles, no node_modules), or one file as index.html42+pack() {43+ if [ -d "$1" ]; then44+ (cd "$1" && find . -type f ! -path '*/.*' ! -path '*/node_modules/*' | LC_ALL=C sort |45+ COPYFILE_DISABLE=1 tar --format=ustar -cf - -T -) > "$tmp/site.tar"46+ elif [ -f "$1" ]; then47+ mkdir "$tmp/one" && cp "$1" "$tmp/one/index.html" && pack "$tmp/one"48+ else49+ die "no such file or directory: $1"50+ fi51+}52+53+# where <path>: absolute path, the key for the breadcrumb file54+where() { if [ -d "$1" ]; then (cd "$1" && pwd); else echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; fi; }55+56+# send <method> <path> [curl args]: sets $code, response body in $tmp/out57+send() {58+ m=$1 p=$2; shift 259+ code=$(curl -sS -o "$tmp/out" -w '%{http_code}' -X "$m" -H "@$tmp/headers" "$@" "$NZ_SERVER$p") || exit 160+}61+62+# finish: print url (stdout), details (stderr), and remember which path it came from63+finish() {64+ case $code in 2*) ;; *) die "$(cat "$tmp/out")" ;; esac65+ url=$(head -n 1 "$tmp/out")66+ sed 1d "$tmp/out" >&267+ if [ -n "${src:-}" ]; then68+ mkdir -p "$NZ_HOME"69+ printf '%s\t%s\n' "$(echo "$url" | sed 's|^.*//||; s|\..*$||')" "$src" >> "$NZ_HOME/sites"70+ fi71+ echo "$url"72+}73+74+if [ "$cmd" = push ]; then75+ [ -n "$a" ] && [ -z "$b" ] || usage76+ pack "$a"77+ src=$(where "$a") id=''78+ [ -n "$new" ] || id=$(awk -F '\t' -v p="$src" '$2 == p { id = $1 } END { print id }' "$NZ_HOME/sites" 2>/dev/null || true)79+ if [ -n "$id" ]; then80+ send PUT "/$id" -H 'content-type: application/x-tar' --data-binary "@$tmp/site.tar"81+ [ "$code" != 404 ] || { echo "nz: $id has expired, pushing a new site" >&2; id=''; }82+ fi83+ [ -n "$id" ] || send POST / -H 'content-type: application/x-tar' --data-binary "@$tmp/site.tar"84+ finish85+else86+ [ -n "$a" ] && [ -z "$new" ] || usage87+ if [ -n "$b" ]; then88+ pack "$b"89+ src=$(where "$b")90+ send PUT "/$a" -H 'content-type: application/x-tar' --data-binary "@$tmp/site.tar"91+ else92+ send PUT "/$a"93+ fi94+ finish95+fi
A site/index.html +177 -0
@@ -0,0 +1,177 @@
1+<!doctype html>2+<html lang="en">3+<meta charset="utf-8">4+<meta name="viewport" content="width=device-width, initial-scale=1">5+<title>nz: push a directory, get a url</title>6+<meta name="description" content="nz is a tiny static host: a POSIX sh client, one Cloudflare Worker, one KV entry per site.">7+<style>8+ :root {9+ --bg: #fbfaf7; --fg: #1d1c1a; --dim: #6b6862; --line: #d9d5cc; --code: #f1eee7; --accent: #c2410c;10+ color-scheme: light dark;11+ }12+ @media (prefers-color-scheme: dark) {13+ :root { --bg: #151413; --fg: #e9e6df; --dim: #9a958b; --line: #3a3733; --code: #1f1d1b; --accent: #fb923c; }14+ }15+ * { box-sizing: border-box; }16+ body { margin: 0; background: var(--bg); color: var(--fg); font: 17px/1.6 ui-sans-serif, system-ui, sans-serif; }17+ main { max-width: 46rem; margin: 0 auto; padding: 4rem 1.25rem 6rem; }18+ h1 { font: 700 3.5rem/1 ui-monospace, "SF Mono", Menlo, monospace; margin: 0 0 .75rem; letter-spacing: -.04em; }19+ h1 span { color: var(--accent); }20+ h3 { font-size: 1.05rem; margin: 2rem 0 .5rem; }21+ h2 { font-size: 1.4rem; margin: 3.5rem 0 .75rem; padding-top: 1rem; border-top: 1px solid var(--line); }22+ .lede { font-size: 1.25rem; color: var(--dim); margin: 0 0 2rem; }23+ a { color: inherit; text-decoration-color: var(--accent); text-underline-offset: .2em; }24+ code, pre { font: 14px/1.55 ui-monospace, "SF Mono", Menlo, monospace; }25+ code { background: var(--code); padding: .1em .3em; border-radius: 3px; }26+ pre { background: var(--code); padding: 1rem 1.1rem; border-radius: 6px; overflow-x: auto; margin: 1rem 0; }27+ pre code { background: none; padding: 0; }28+ .c { color: var(--dim); }29+ .a { color: var(--accent); }30+ table { width: 100%; border-collapse: collapse; font-size: .95rem; margin: 1rem 0; }31+ th, td { text-align: left; padding: .45rem .6rem .45rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }32+ th { font-weight: 600; color: var(--dim); font-size: .85rem; }33+ td:first-child { white-space: nowrap; }34+ ul { padding-left: 1.2rem; }35+ li { margin: .3rem 0; }36+ figure { margin: 1.5rem 0; }37+ svg { width: 100%; height: auto; display: block; }38+ svg text { font: 13px ui-monospace, "SF Mono", Menlo, monospace; fill: var(--fg); }39+ svg .dim { fill: var(--dim); }40+ svg .box { fill: var(--code); stroke: var(--line); stroke-width: 1.5; }41+ svg .kv { fill: var(--bg); stroke: var(--accent); stroke-width: 1.5; }42+ svg .arrow { stroke: var(--dim); stroke-width: 1.5; fill: none; marker-end: url(#head); }43+ svg .accent { stroke: var(--accent); }44+ figcaption { color: var(--dim); font-size: .9rem; margin-top: .5rem; }45+ .omit { color: var(--dim); }46+ footer { margin-top: 4rem; color: var(--dim); font-size: .9rem; }47+</style>48+49+<main>50+ <h1>nz<span>.</span></h1>51+ <p class="lede">Push a directory of HTML, get a url back. A POSIX shell script, one Cloudflare Worker, and one KV entry per site.</p>52+53+<pre><code><span class="c">$</span> nz push ./report --ttl 7d --password meow54+<span class="c">expires 2026-11-10T19:40:02.000Z, password protected</span>55+https://k3x9q.example.com/56+57+<span class="c">$</span> nz push ./report <span class="c"># same path, same url, new content</span>58+<span class="c">$</span> nz update k3x9q --no-password <span class="c"># policy only, content kept</span></code></pre>59+60+ <p>A small take on <a href="https://github.com/FelineStateMachine/nzip">nzip</a>, written the way61+ <a href="https://fixiproject.org">fixi</a> is: few moving parts, nothing to build, and code short enough to read.62+ It does four things: <b>push</b>, <b>update</b>, <b>ttl</b>, and <b>password</b>.</p>63+64+ <h2>Three files</h2>65+ <table>66+ <tr><th>file</th><th>what</th><th>lines</th></tr>67+ <tr><td><code>worker.js</code></td><td>Cloudflare Worker: plain JS, no dependencies, no build</td><td>170</td></tr>68+ <tr><td><code>nz</code></td><td>the CLI: POSIX <code>sh</code>, using only <code>tar</code> and <code>curl</code></td><td>95</td></tr>69+ <tr><td><code>test.sh</code></td><td>end-to-end checks against <code>wrangler dev</code></td><td>60</td></tr>70+ </table>71+72+ <h2>Architecture</h2>73+ <figure>74+ <svg viewBox="0 0 720 330" role="img" aria-labelledby="arch-t">75+ <title id="arch-t">nz tars a directory and sends it to the Worker on the apex host, which stores it in KV. Browsers request id.domain, and the Worker reads the KV entry and serves one file out of the tar.</title>76+ <defs>77+ <marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">78+ <path d="M0 0 L10 5 L0 10 z" fill="currentColor" style="fill: var(--dim)"/>79+ </marker>80+ </defs>81+82+ <rect class="box" x="10" y="30" width="170" height="86" rx="6"/>83+ <text x="26" y="58">nz push ./dir</text>84+ <text x="26" y="80" class="dim">find | tar ustar</text>85+ <text x="26" y="100" class="dim">curl --data-binary</text>86+87+ <rect class="box" x="10" y="214" width="170" height="86" rx="6"/>88+ <text x="26" y="242">browser</text>89+ <text x="26" y="264" class="dim">GET k3x9q.domain/</text>90+ <text x="26" y="284" class="dim">POST password form</text>91+92+ <rect class="box" x="275" y="20" width="200" height="290" rx="6"/>93+ <text x="291" y="48">worker.js</text>94+ <text x="291" y="82" class="dim">apex host: api</text>95+ <text x="291" y="102">POST / new</text>96+ <text x="291" y="122">PUT /&lt;id&gt; update</text>97+ <text x="291" y="142" class="dim">bearer TOKEN</text>98+ <line x1="291" y1="162" x2="459" y2="162" stroke="var(--line)"/>99+ <text x="291" y="190" class="dim">&lt;id&gt;.host: sites</text>100+ <text x="291" y="210">untar, find path</text>101+ <text x="291" y="230">password gate</text>102+ <text x="291" y="250">signed cookie</text>103+ <text x="291" y="270" class="dim">rate-limited unlock</text>104+105+ <rect class="kv" x="570" y="95" width="140" height="140" rx="6"/>106+ <text x="586" y="123">KV SITES</text>107+ <text x="586" y="153" class="dim">key id</text>108+ <text x="586" y="173" class="dim">value tar</text>109+ <text x="586" y="193" class="dim">meta exp, pw</text>110+ <text x="586" y="216" class="a" style="fill: var(--accent)">expiration</text>111+112+ <path class="arrow" d="M180 73 L271 73"/>113+ <text x="190" y="64" class="dim">tar</text>114+ <path class="arrow" d="M180 257 L271 257"/>115+ <path class="arrow" d="M475 110 L566 140"/>116+ <text x="492" y="110" class="dim">put</text>117+ <path class="arrow" d="M566 200 L475 230"/>118+ <text x="482" y="243" class="dim">get</text>119+ </svg>120+ <figcaption>A site is a single KV entry. Pushing writes it once, and serving a page reads it once. KV's built-in expiration deletes it when the TTL runs out.</figcaption>121+ </figure>122+123+ <p>One entry holds everything about a site:</p>124+<pre><code>key <span class="a">k3x9q</span> <span class="c">5 chars, a-z0-9</span>125+value &lt;ustar bytes&gt; <span class="c">the whole site, 25 MiB max</span>126+metadata { exp: 1795895602, <span class="c">unix seconds, or null for never</span>127+ pw: "&lt;salt&gt;.&lt;pbkdf2&gt;" } <span class="c">absent when public</span>128+expiration exp <span class="c">KV deletes the entry for us</span></code></pre>129+ <p>That covers what nzip needed a database, a blob store, a cron job and a garbage collector for.</p>130+131+ <h3>Push</h3>132+ <ul>133+ <li><code>nz</code> finds files (skipping dotfiles and <code>node_modules</code>), tars them as ustar, and writes headers to a temporary file so the token and password stay out of <code>ps</code>.</li>134+ <li>If <code>~/.config/nz/sites</code> has an id for this path, it <code>PUT</code>s to that id. If that site has expired (404), or there is no id yet, it <code>POST</code>s a new one and records <code>id&lt;TAB&gt;path</code>.</li>135+ <li>The Worker checks that the tar parses, works out the expiry and password, and writes the entry with one <code>put</code>. Headers you leave out on update keep their current values, and an empty body keeps the current content.</li>136+ </ul>137+138+ <h3>Serve</h3>139+ <ul>140+ <li>Every site is <code>https://&lt;id&gt;.domain/</code>, so each one is its own browser origin, with its own cookies and storage.</li>141+ <li>The Worker reads the entry, walks the ustar headers (512-byte blocks, octal sizes), and returns the matching file. <code>/</code> maps to <code>index.html</code>, and a directory without a trailing slash gets a 308 redirect.</li>142+ </ul>143+144+ <h3>Password</h3>145+ <ul>146+ <li>Only a PBKDF2-SHA256 verifier is stored, never the password. A locked site shows a small form, and unlock attempts are limited to 5 per minute per IP and site.</li>147+ <li>Unlocking sets a host-only <code>__Host-nz</code> cookie, valid for 7 days, whose HMAC covers the id, the expiry and the verifier. Changing the password changes the verifier, which invalidates every outstanding cookie with no counter or session table.</li>148+ </ul>149+150+ <h2>API</h2>151+ <p>The CLI is a thin wrapper around this, so <code>curl</code> works just as well:</p>152+<pre><code>tar --format=ustar -cf - -C site . |153+ curl -H "authorization: Bearer $TOKEN" \154+ -H 'nz-ttl: 7d' -H 'nz-password: meow' \155+ --data-binary @- https://example.com/ <span class="c"># 201, new site</span>156+157+curl -X PUT ... https://example.com/k3x9q <span class="c"># 200, update</span>158+curl -X PUT -H 'nz-password;' ... <span class="c"># empty: no password</span></code></pre>159+ <p>The response is the url on line one and a details line on line two. TTL takes <code>30m</code>, <code>12h</code>, <code>14d</code>, <code>2w</code> or <code>never</code>, and the default is <code>DEFAULT_TTL</code> (14d).</p>160+161+ <h2>Left out on purpose</h2>162+ <p class="omit">Vaults, aliases, push history and revert, cross-site dedup, notifications, feedback, app origins, listing and deletion (let a site expire, or set <code>--ttl 1m</code>). If you need these, use nzip.</p>163+ <p>Two caveats come with KV: an update can take up to about a minute to reach other regions, and the free tier allows 1,000 writes (pushes) a day.</p>164+165+ <h2>Install</h2>166+<pre><code>wrangler kv namespace create SITES <span class="c"># id into wrangler.jsonc</span>167+<span class="c"># set DOMAIN and routes; add a proxied DNS record: * AAAA 100::</span>168+wrangler secret put TOKEN169+wrangler deploy170+171+ln -s "$PWD/nz" ~/bin/nz172+printf 'NZ_SERVER=https://example.com\nNZ_TOKEN=...\n' &gt; ~/.config/nz/config</code></pre>173+174+ <footer>175+ <a href="https://src.rest/cat/nz/">src.rest/cat/nz</a> | 0BSD176+ </footer>177+</main>
A test.sh +60 -0
@@ -0,0 +1,60 @@
1+#!/bin/sh2+# End-to-end test: worker.js under `wrangler dev`, driven by ./nz. Needs wrangler3+# (WRANGLER=path, default `npx wrangler`). Routes make wrangler dev rewrite the4+# Host header, so this runs from a routeless copy of the config.5+set -u6+cd "$(dirname "$0")"7+port=${PORT:-8787} t=$(mktemp -d) fails=08+trap 'kill $pid 2>/dev/null; rm -rf "$t"' EXIT9+10+sed '/"routes"/,/\]/d' wrangler.jsonc | sed "s|\"main\": \"worker.js\"|\"main\": \"$PWD/worker.js\"|" > "$t/wrangler.jsonc"11+${WRANGLER:-npx wrangler} dev -c "$t/wrangler.jsonc" --port "$port" --persist-to "$t/state" \12+ --var "DOMAIN:localhost:$port" --var TOKEN:test > "$t/log" 2>&1 &13+pid=$!14+for _ in $(seq 60); do curl -s -o /dev/null "localhost:$port" && break; sleep 1; done15+16+export NZ_HOME=$t/home NZ_SERVER=http://localhost:$port NZ_TOKEN=test17+is() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: got '$2' want '$3'"; fails=$((fails + 1)); fi; }18+get() { curl -s "$@"; }19+code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }20+idof() { echo "$1" | sed 's|^.*//||; s|\..*$||'; }21+22+s=$t/site23+mkdir -p "$s/sub/.git" "$s/node_modules"24+echo '<h1>v1</h1>' > "$s/index.html"; echo 'body{}' > "$s/style.css"; echo sub > "$s/sub/index.html"25+echo x > "$s/sub/.git/x"; echo x > "$s/node_modules/y"; echo x > "$s/.env"26+27+u=$(./nz push "$s" 2>/dev/null)28+is 'push prints a site url' "$(echo "$u" | grep -c "^http://[a-z0-9]\{5\}\.localhost:$port/$")" 129+is 'serves index' "$(get "$u")" '<h1>v1</h1>'30+is 'serves assets with a type' "$(curl -s -o /dev/null -w '%{content_type}' "${u}style.css")" 'text/css; charset=utf-8'31+is 'directory redirects to slash' "$(code "${u}sub")" 30832+is 'serves nested index' "$(get "${u}sub/")" sub33+is 'skips dot dirs' "$(code "${u}sub/.git/x")" 40434+is 'skips dotfiles' "$(code "${u}.env")" 40435+is 'skips node_modules' "$(code "${u}node_modules/y")" 40436+is 'default ttl is 14d' "$(./nz update "$(idof "$u")" 2>&1 >/dev/null | grep -c expires)" 137+38+echo '<h1>v2</h1>' > "$s/index.html"39+is 'push again reuses the url' "$(./nz push "$s" --ttl 1h 2>/dev/null)" "$u"40+is 'and replaces content' "$(get "$u")" '<h1>v2</h1>'41+is '--new makes another url' "$(./nz push "$s" --new 2>/dev/null | grep -c "$u")" 042+43+echo '<b>report</b>' > "$t/report.html"44+r=$(./nz push "$t/report.html" --password meow 2>/dev/null)45+is 'password locks the site' "$(code "$r")" 40146+is 'wrong password is refused' "$(code -X POST -d password=nope "$r")" 40147+cookie=$(curl -s -D - -o /dev/null -X POST -d password=meow "$r" | sed -n 's/^set-cookie: \(__Host-nz=[^;]*\).*/\1/ip')48+is 'right password unlocks' "$(get -H "cookie: $cookie" "$r")" '<b>report</b>'49+./nz update "$(idof "$r")" --password purr 2>/dev/null >&250+is 'new password revokes cookies' "$(code -H "cookie: $cookie" "$r")" 40151+is 'update keeps content' "$(get -H "cookie: $(curl -s -D - -o /dev/null -X POST -d password=purr "$r" | sed -n 's/^set-cookie: \(__Host-nz=[^;]*\).*/\1/ip')" "$r")" '<b>report</b>'52+is 'no-password + never' "$(./nz update "$(idof "$r")" --no-password --ttl never 2>&1 >/dev/null)" 'never expires'53+is 'is public again' "$(get "$r")" '<b>report</b>'54+55+is 'bad ttl is refused' "$(./nz push "$s" --ttl 3x 2>&1)" 'nz: ttl must look like 30m, 12h, 14d, 2w or never'56+is 'bad token is refused' "$(NZ_TOKEN=nope ./nz push "$s" 2>&1)" 'nz: unauthorized'57+is 'unknown id' "$(./nz update zzzzz 2>&1)" 'nz: zzzzz not found (expired?)'58+is 'not a tar' "$(curl -s -X POST -H 'authorization: Bearer test' --data-binary hello "$NZ_SERVER/")" 'body must be a ustar archive with at least one file'59+60+[ $fails = 0 ] && echo "all passed" || { echo "$fails failed"; exit 1; }
A worker.js +170 -0
@@ -0,0 +1,170 @@
1+// nz: push a tar, get a url. One KV entry per site: the value is the tar,2+// the metadata is { exp, pw }. KV expiration is the garbage collector.3+//4+// POST / body=tar -> new site, 201 "https://<id>.DOMAIN/\n<info>"5+// PUT /<id> body=tar -> replace content (empty body keeps it)6+// headers authorization: Bearer TOKEN7+// nz-ttl: 30m|12h|14d|2w|never (omit to keep / default)8+// nz-password: <pw> | nz-password; (empty removes)9+10+const ID = /^[a-z0-9]{5}$/, ABC = 'abcdefghijklmnopqrstuvwxyz0123456789', DAY = 8640011+const UNITS = { m: 60, h: 3600, d: DAY, w: 7 * DAY }12+const MIME = {13+ html: 'text/html; charset=utf-8', css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8',14+ mjs: 'text/javascript; charset=utf-8', json: 'application/json', txt: 'text/plain; charset=utf-8',15+ md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8', xml: 'application/xml',16+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',17+ webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon', pdf: 'application/pdf',18+ woff2: 'font/woff2', woff: 'font/woff', wasm: 'application/wasm', mp4: 'video/mp4', webm: 'video/webm',19+}20+const enc = new TextEncoder(), dec = new TextDecoder()21+const now = () => Math.floor(Date.now() / 1000)22+const text = (body, status = 200) => new Response(body + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } })23+const fail = (status, msg) => { throw text(msg, status) }24+25+export default {26+ async fetch(req, env) {27+ const url = new URL(req.url), sub = '.' + env.DOMAIN28+ try {29+ if (url.host === env.DOMAIN) return await api(req, env, url)30+ if (url.host.endsWith(sub)) return await site(req, env, url, url.host.slice(0, -sub.length))31+ return text('not found', 404)32+ } catch (e) {33+ if (e instanceof Response) return e34+ throw e35+ }36+ },37+}38+39+// ---- api (apex host, bearer token) ----40+41+async function api(req, env, url) {42+ if (!same(req.headers.get('authorization') ?? '', `Bearer ${env.TOKEN}`)) fail(401, 'unauthorized')43+ const [, id, rest] = url.pathname.split('/')44+ if (req.method === 'POST' && !id) return save(req, env, url, await newId(env), null)45+ if (req.method === 'PUT' && ID.test(id) && rest === undefined) {46+ const old = await env.SITES.getWithMetadata(id, 'arrayBuffer')47+ return old.value ? save(req, env, url, id, old) : fail(404, `${id} not found (expired?)`)48+ }49+ fail(404, 'not found')50+}51+52+async function save(req, env, url, id, old) {53+ let tar = await req.arrayBuffer()54+ if (!tar.byteLength && old) tar = old.value55+ if (tar.byteLength > 25 << 20) fail(413, 'too large: 25 MiB max')56+ if (!untar(tar).size) fail(400, 'body must be a ustar archive with at least one file')57+58+ const h = req.headers, meta = { ...old?.metadata }59+ if (h.has('nz-ttl') || !old) meta.exp = expiry(h.get('nz-ttl') ?? env.DEFAULT_TTL ?? '14d')60+ if (h.has('nz-password')) meta.pw = h.get('nz-password') ? await hash(h.get('nz-password')) : undefined61+ if (meta.exp) meta.exp = Math.max(meta.exp, now() + 60) // KV refuses anything sooner62+63+ await env.SITES.put(id, tar, { metadata: meta, ...(meta.exp && { expiration: meta.exp }) })64+ const info = [meta.exp ? `expires ${new Date(meta.exp * 1000).toISOString()}` : 'never expires']65+ if (meta.pw) info.push('password protected')66+ return text(`${url.protocol}//${id}.${env.DOMAIN}/\n${info.join(', ')}`, old ? 200 : 201)67+}68+69+function expiry(ttl) {70+ if (ttl === 'never' || ttl === '0') return null71+ const m = /^(\d+)([mhdw])$/.exec(ttl) ?? fail(400, 'ttl must look like 30m, 12h, 14d, 2w or never')72+ return now() + m[1] * UNITS[m[2]]73+}74+75+async function newId(env) {76+ for (;;) {77+ const id = [...crypto.getRandomValues(new Uint8Array(5))].map(b => ABC[b % 36]).join('')78+ if (!await env.SITES.get(id, 'stream')) return id79+ }80+}81+82+// ---- sites (<id>.DOMAIN) ----83+84+async function site(req, env, url, id) {85+ if (!ID.test(id)) fail(404, 'not found')86+ const { value, metadata: meta } = await env.SITES.getWithMetadata(id, 'arrayBuffer')87+ if (!value) fail(404, 'not found')88+ if (meta?.pw && req.method === 'POST') return unlock(req, env, url, id, meta.pw)89+ if (meta?.pw && !await unlocked(req, env, id, meta.pw)) return lock(401)90+ if (req.method !== 'GET' && req.method !== 'HEAD') fail(405, 'method not allowed')91+92+ const files = untar(value)93+ let path = url.pathname.slice(1)94+ try { path = decodeURIComponent(path) } catch { fail(400, 'bad path') }95+ if (path === '' || path.endsWith('/')) path += 'index.html'96+ else if (!files.has(path) && files.has(path + '/index.html')) return Response.redirect(new URL(url.pathname + '/' + url.search, url), 308)97+98+ const body = files.get(path) ?? fail(404, 'not found')99+ return new Response(req.method === 'HEAD' ? null : body, {100+ headers: {101+ 'content-type': MIME[path.split('.').pop().toLowerCase()] ?? 'application/octet-stream',102+ 'cache-control': meta?.pw ? 'private, no-store' : 'no-cache',103+ 'x-content-type-options': 'nosniff',104+ },105+ })106+}107+108+// ustar: 512-byte headers, name at 0 (prefix at 345), octal size at 124, type at 156.109+// Dot-segments (.git, ._AppleDouble) are dropped.110+function untar(buf) {111+ const b = new Uint8Array(buf), files = new Map()112+ const str = (at, len) => dec.decode(b.subarray(at, at + len)).replace(/\0[^]*$/, '')113+ for (let at = 0; at + 512 <= b.length && b[at];) {114+ const size = parseInt(str(at + 124, 12).trim() || '0', 8), type = str(at + 156, 1)115+ const name = [str(at + 345, 155), str(at, 100)].filter(Boolean).join('/').replace(/^(\.\/)+/, '')116+ if (!(size >= 0) || at + 512 + size > b.length) fail(400, 'truncated or invalid tar')117+ if ((type === '0' || type === '') && !name.split('/').some(s => s.startsWith('.')))118+ files.set(name, b.subarray(at + 512, at + 512 + size))119+ at += 512 + Math.ceil(size / 512) * 512120+ }121+ return files122+}123+124+// ---- passwords: pbkdf2 verifier in metadata, signed host-only cookie ----125+// The cookie signs the verifier, so changing the password logs everyone out.126+127+const hex = buf => [...new Uint8Array(buf)].map(x => x.toString(16).padStart(2, '0')).join('')128+const unhex = s => Uint8Array.from(s.match(/../g), x => parseInt(x, 16))129+const same = (a, b) => a.length === b.length && crypto.subtle.timingSafeEqual(enc.encode(a), enc.encode(b))130+131+async function pbkdf2(pw, salt) {132+ const key = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveBits'])133+ return hex(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 100000 }, key, 256))134+}135+async function hash(pw) {136+ const salt = crypto.getRandomValues(new Uint8Array(16))137+ return hex(salt) + '.' + await pbkdf2(pw, salt)138+}139+async function sign(env, msg) {140+ const key = await crypto.subtle.importKey('raw', enc.encode(env.TOKEN), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])141+ return hex(await crypto.subtle.sign('HMAC', key, enc.encode(msg)))142+}143+144+async function unlocked(req, env, id, pw) {145+ const [exp, sig] = (/(?:^|;\s*)__Host-nz=(\d+)\.([0-9a-f]+)/.exec(req.headers.get('cookie')) ?? []).slice(1)146+ return exp > now() && same(sig, await sign(env, `${id}.${exp}.${pw}`))147+}148+149+async function unlock(req, env, url, id, pw) {150+ const key = `${req.headers.get('cf-connecting-ip')}:${id}`151+ if (env.UNLOCK && !(await env.UNLOCK.limit({ key })).success) fail(429, 'too many tries, wait a minute')152+ const given = (await req.formData().catch(() => null))?.get('password')153+ const [salt, want] = pw.split('.')154+ if (typeof given !== 'string' || !same(await pbkdf2(given, unhex(salt)), want)) return lock(401, 'wrong password')155+ const exp = now() + 7 * DAY156+ return new Response(null, {157+ status: 303,158+ headers: {159+ location: url.pathname + url.search,160+ 'set-cookie': `__Host-nz=${exp}.${await sign(env, `${id}.${exp}.${pw}`)}; Max-Age=${7 * DAY}; Path=/; HttpOnly; Secure; SameSite=Lax`,161+ },162+ })163+}164+165+const lock = (status, msg = '') => new Response(`<!doctype html>166+<meta charset=utf-8><meta name=viewport content="width=device-width"><meta name=robots content=noindex>167+<title>locked</title>168+<style>body{font:16px system-ui;display:grid;place-items:center;height:90vh;margin:0}input,button{font:inherit;padding:.4em .6em}p{color:#b00}</style>169+<form method=post><p>${msg}</p><input type=password name=password autofocus required placeholder=password> <button>open</button></form>170+`, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
A wrangler.jsonc +14 -0