cat / nz
clone

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

main
nz / site / index.html
index.html 10.1K
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 footer { margin-top: 4rem; color: var(--dim); font-size: .9rem; }46</style>4748<main>49 <h1>nz<span>.</span></h1>50 <p class="lede">Push a directory of HTML, get a url back. A small CLI for macOS, Linux and Windows, one Cloudflare Worker, and one KV entry per site.</p>5152<pre><code><span class="c">$</span> nz push ./report --ttl 7d --password meow53<span class="c">expires 2026-11-10T19:40:02.000Z, password protected</span>54https://k3x9q.example.com/5556<span class="c">$</span> nz push ./report <span class="c"># same path, same url, new content</span>57<span class="c">$</span> nz update k3x9q --no-password <span class="c"># policy only, content kept</span></code></pre>585960 <h2>The files</h2>61 <table>62 <tr><th>file</th><th>what</th><th>lines</th></tr>63 <tr><td><code>worker.js</code></td><td>Cloudflare Worker: plain JS, no dependencies, no build</td><td>170</td></tr>64 <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>65 <tr><td><code>nz.ps1</code></td><td>the same CLI for PowerShell, using the <code>tar.exe</code> and <code>curl.exe</code> built into Windows</td><td>146</td></tr>66 <tr><td><code>test.sh</code></td><td>end-to-end checks against <code>wrangler dev</code></td><td>61</td></tr>67 </table>6869 <h2>Architecture</h2>70 <figure>71 <svg viewBox="0 0 720 330" role="img" aria-labelledby="arch-t">72 <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>73 <defs>74 <marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">75 <path d="M0 0 L10 5 L0 10 z" fill="currentColor" style="fill: var(--dim)"/>76 </marker>77 </defs>7879 <rect class="box" x="10" y="30" width="170" height="86" rx="6"/>80 <text x="26" y="58">nz push ./dir</text>81 <text x="26" y="80" class="dim">find | tar ustar</text>82 <text x="26" y="100" class="dim">curl --data-binary</text>8384 <rect class="box" x="10" y="214" width="170" height="86" rx="6"/>85 <text x="26" y="242">browser</text>86 <text x="26" y="264" class="dim">GET k3x9q.domain/</text>87 <text x="26" y="284" class="dim">POST password form</text>8889 <rect class="box" x="275" y="20" width="200" height="290" rx="6"/>90 <text x="291" y="48">worker.js</text>91 <text x="291" y="82" class="dim">apex host: api</text>92 <text x="291" y="102">POST / new</text>93 <text x="291" y="122">PUT /&lt;id&gt; update</text>94 <text x="291" y="142" class="dim">bearer TOKEN</text>95 <line x1="291" y1="162" x2="459" y2="162" stroke="var(--line)"/>96 <text x="291" y="190" class="dim">&lt;id&gt;.host: sites</text>97 <text x="291" y="210">untar, find path</text>98 <text x="291" y="230">password gate</text>99 <text x="291" y="250">signed cookie</text>100 <text x="291" y="270" class="dim">rate-limited unlock</text>101102 <rect class="kv" x="570" y="95" width="140" height="140" rx="6"/>103 <text x="586" y="123">KV SITES</text>104 <text x="586" y="153" class="dim">key id</text>105 <text x="586" y="173" class="dim">value tar</text>106 <text x="586" y="193" class="dim">meta exp, pw</text>107 <text x="586" y="216" class="a" style="fill: var(--accent)">expiration</text>108109 <path class="arrow" d="M180 73 L271 73"/>110 <text x="190" y="64" class="dim">tar</text>111 <path class="arrow" d="M180 257 L271 257"/>112 <path class="arrow" d="M475 110 L566 140"/>113 <text x="492" y="110" class="dim">put</text>114 <path class="arrow" d="M566 200 L475 230"/>115 <text x="482" y="243" class="dim">get</text>116 </svg>117 <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>118 </figure>119120 <p>One entry holds everything about a site:</p>121<pre><code>key <span class="a">k3x9q</span> <span class="c">5 chars, a-z0-9</span>122value &lt;ustar bytes&gt; <span class="c">the whole site, 25 MiB max</span>123metadata { exp: 1795895602, <span class="c">unix seconds, or null for never</span>124 pw: "&lt;salt&gt;.&lt;pbkdf2&gt;" } <span class="c">absent when public</span>125expiration exp <span class="c">KV deletes the entry for us</span></code></pre>126127 <h3>Push</h3>128 <ul>129 <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>130 <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>131 <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>132 </ul>133134 <h3>Serve</h3>135 <ul>136 <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>137 <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>138 </ul>139140 <h3>Password</h3>141 <ul>142 <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>143 <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.</li>144 </ul>145146 <h2>API</h2>147 <p>The CLI is a thin wrapper around this, so <code>curl</code> works just as well:</p>148<pre><code>tar --format=ustar -cf - -C site . |149 curl -H "authorization: Bearer $TOKEN" \150 -H 'nz-ttl: 7d' -H 'nz-password: meow' \151 --data-binary @- https://example.com/ <span class="c"># 201, new site</span>152153curl -X PUT ... https://example.com/k3x9q <span class="c"># 200, update</span>154curl -X PUT -H 'nz-password;' ... <span class="c"># empty: no password</span></code></pre>155 <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>156157158 <h2>Install</h2>159<pre><code>wrangler kv namespace create SITES <span class="c"># id into wrangler.jsonc</span>160<span class="c"># set DOMAIN and routes; add a proxied DNS record: * AAAA 100::</span>161wrangler secret put TOKEN162wrangler deploy163164ln -s "$PWD/nz" ~/bin/nz165printf 'NZ_SERVER=https://example.com\nNZ_TOKEN=...\n' &gt; ~/.config/nz/config</code></pre>166 <p>On Windows 10 or later, put <code>nz.ps1</code> on your <code>PATH</code> and write the same config:</p>167<pre><code>New-Item -ItemType Directory -Force ~\.config\nz | Out-Null168"NZ_SERVER=https://example.com`nNZ_TOKEN=..." | Set-Content -Encoding ascii ~\.config\nz\config169nz.ps1 push .\site --ttl 7d --password meow</code></pre>170171 <footer>172 <a href="https://src.rest/cat/nz/">src.rest/cat/nz</a> | 0BSD173 </footer>174</main>