clone

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

Add hosted source recovery

Dami committed 69fccd5
commit
69fccd5ee316bf0d6514840e139706aa74f3af63
parent
65d77583fcfd2812276f4b15b75d5cdb85f5cac6
author
Dami <dami.etoile@gmail.com>
9 files changed, +282 -10
MREADME.md16+++-
Mcli/README.md12++-
Acli/commands/download.ts132++++++++++++++++++++++++++++++
Acli/commands/download_test.ts49+++++++++++
Mcli/lib/api.ts28++++++
Mcli/main.ts6+-
Mdeno.json2+-
Mshared/types.ts7++
Mworker/src/api.ts40+++++++++-
M README.md +12 -4
@@ -5,7 +5,8 @@
55 _A personal share tool. Deno CLI → Cloudflare Worker → R2 + D1. No daemon, no build step, no66 redeploys._77 8-[![JSR](https://jsr.io/badges/@nzip/cli)](https://jsr.io/@nzip/cli) · [args.io/cat/nzip](https://args.io/cat/nzip)8+[![JSR](https://jsr.io/badges/@nzip/cli)](https://jsr.io/@nzip/cli) ·9+[args.io/cat/nzip](https://args.io/cat/nzip)910 1011 ```console1112 $ nzip push ./demo work:demo --ttl 30d
@@ -52,9 +53,9 @@ three target forms:
5253 - **Single-user by design.** One bearer token, stored as a Worker secret and in5354 `~/.config/nzip/config.json` (mode 0600).5455 - **Locates itself.** `nzip where <target>` prints the local directory this machine pushed a site55- from. A breadcrumb registry (`~/.config/nzip/paths.json`) records the source path on every push and56- self-cleans: expired entries drop on write, `rm` forgets its entry, and `ls` reconciles against the57- live set. Use it as `cd "$(nzip where personal:plan)"`.56+ from. A breadcrumb registry (`~/.config/nzip/paths.json`) records the source path on every push57+ and self-cleans: expired entries drop on write, `rm` forgets its entry, and `ls` reconciles58+ against the live set. Use it as `cd "$(nzip where personal:plan)"`.5859 - **Vault guardrail.** An optional `"allowVaults": ["home"]` in `config.json` restricts which vaults5960 this install may target by name. Pushes or aliases outside the list are refused before any upload,6061 so a home-project agent can't drop a doc into a vault that sits adjacent to what you share
@@ -67,6 +68,7 @@ nzip auth [--server URL] [--token T] authenticate and save config
6768 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)6869 nzip vault ls | default <name> list vaults / set the default6970 nzip push <dir|file> [target] [--ttl 14d|30d|forever]71+nzip download <target> [dir] [--overwrite] recover the current hosted bundle7072 nzip share <target> [--ttl …] [--password PW | --no-password]7173 nzip ls [vault] list sites7274 nzip where <target> print the local dir this machine pushed from
@@ -80,6 +82,12 @@ and `node_modules`, and honor a `.nzipignore` (one glob per line). Single-file s
8082 at the bare address (`/2a3f`); multi-file bundles redirect to `/2a3f/` so relative asset URLs8183 resolve.8284 85+`nzip download work:demo ./recovered-demo` recovers the exact current bundle from the authenticated86+server when the original local directory is unavailable. It refuses non-empty destinations unless87+`--overwrite` is passed, and verifies every downloaded file against the hosted manifest. It can only88+restore uploaded files—not dotfiles, `.nzipignore`, or other local project metadata excluded from a89+push.90+8391 ## Architecture8492 8593 ```mermaid
M cli/README.md +9 -3
@@ -29,9 +29,9 @@ nzip vault add work # register a named vault
2929 nzip push ./site work:demo --ttl 30d # → https://share.example.com/12d83030 ```3131 32-Config is saved to `~/.config/nzip/config.json` (mode 0600), so later commands just work. On a second33-machine, install from JSR and re-run `nzip auth` with the same server and token; every vault and site34-is already there.32+Config is saved to `~/.config/nzip/config.json` (mode 0600), so later commands just work. On a33+second machine, install from JSR and re-run `nzip auth` with the same server and token; every vault34+and site is already there.3535 3636 ## Commands3737
@@ -40,6 +40,7 @@ nzip auth [--server URL] [--token T] authenticate and save config
4040 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)4141 nzip vault ls | default <name> list vaults / set the default4242 nzip push <dir|file> [target] [--ttl 14d|30d|forever]43+nzip download <target> [dir] [--overwrite]4344 nzip share <target> [--ttl …] [--password PW | --no-password]4445 nzip ls [vault] list sites4546 nzip where <target> print the local dir this machine pushed from
@@ -48,5 +49,10 @@ nzip status server + vault overview
4849 nzip revert <target> [--to N] [--list] repoint to a previous push4950 ```5051 52+`nzip download work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty53+directory. It uses the configured bearer token, verifies file hashes, and never exposes source via54+the public page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were55+never stored.56+5157 See the [project README](https://github.com/FelineStateMachine/nzip) for how addresses work,5258 self-hosting setup, and architecture. MIT licensed.
A cli/commands/download.ts +132 -0
@@ -0,0 +1,132 @@
1+// Recover the current uploaded bundle for a site into a local directory.2+3+import { dirname, join } from "@std/path";4+import { sha256hex, validatePath } from "@nzip/shared";5+import { ApiClient, resolveCliTarget } from "../lib/api.ts";6+import { formatBytes } from "../lib/bundle.ts";7+import type { Config } from "../lib/config.ts";8+import { bold, cyan, emit, fail, green } from "../lib/fmt.ts";9+10+const DOWNLOAD_CONCURRENCY = 6;11+12+interface SourceApi {13+ source(target: string): ReturnType<ApiClient["source"]>;14+ downloadSourceBlob(target: string, hash: string): ReturnType<ApiClient["downloadSourceBlob"]>;15+}16+17+export interface DownloadResult {18+ address: string;19+ manifestHash: string;20+ destination: string;21+ files: number;22+ bytes: number;23+}24+25+function targetOrFail(raw: string | undefined, config: Config): string {26+ if (!raw) fail("usage: nzip download <target> [dir] [--overwrite]");27+ try {28+ return resolveCliTarget(raw, config);29+ } catch (e) {30+ return fail((e as Error).message);31+ }32+}33+34+async function prepareDestination(path: string, overwrite: boolean): Promise<void> {35+ try {36+ const stat = await Deno.lstat(path);37+ if (!stat.isDirectory || stat.isSymlink) fail(`destination is not a directory: ${path}`);38+ for await (const _entry of Deno.readDir(path)) {39+ if (!overwrite) fail(`destination is not empty: ${path} (pass --overwrite to replace files)`);40+ break;41+ }42+ } catch (e) {43+ if (e instanceof Deno.errors.NotFound) {44+ await Deno.mkdir(path, { recursive: true });45+ return;46+ }47+ throw e;48+ }49+}50+51+async function makeParent(path: string): Promise<void> {52+ await Deno.mkdir(dirname(path), { recursive: true });53+}54+55+async function writeFile(path: string, bytes: Uint8Array, overwrite: boolean): Promise<void> {56+ try {57+ const existing = await Deno.lstat(path);58+ if (existing.isDirectory || existing.isSymlink) fail(`refusing to replace non-file: ${path}`);59+ if (!overwrite) fail(`destination file already exists: ${path}`);60+ } catch (e) {61+ if (!(e instanceof Deno.errors.NotFound)) throw e;62+ }63+ const temporary = `${path}.nzip-download-${crypto.randomUUID()}`;64+ try {65+ await Deno.writeFile(temporary, bytes);66+ await Deno.rename(temporary, path);67+ } finally {68+ await Deno.remove(temporary).catch(() => {});69+ }70+}71+72+export async function cmdDownload(73+ config: Config,74+ raw: string | undefined,75+ destinationRaw: string | undefined,76+ overwrite: boolean,77+): Promise<void> {78+ const target = targetOrFail(raw, config);79+ const api = new ApiClient(config);80+ const result = await downloadSource(api, target, destinationRaw, overwrite);81+82+ emit(83+ () =>84+ console.log(85+ `${green("✓")} downloaded ${bold(result.address)} → ${86+ cyan(result.destination)87+ } (${result.files} files, ${formatBytes(result.bytes)})`,88+ ),89+ { ok: true, ...result },90+ );91+}92+93+/** Reconstruct a source response locally; exported to keep recovery behavior testable. */94+export async function downloadSource(95+ api: SourceApi,96+ target: string,97+ destinationRaw: string | undefined,98+ overwrite: boolean,99+): Promise<DownloadResult> {100+ const source = await api.source(target);101+ const destination = destinationRaw ?? `${source.address}-source`;102+ await prepareDestination(destination, overwrite);103+104+ const files = Object.entries(source.manifest.files);105+ const totalBytes = files.reduce((total, [, entry]) => total + entry.s, 0);106+ const queue = [...files];107+ let downloaded = 0;108+ const workers = Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, queue.length) }, async () => {109+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {110+ const [relativePath, entry] = next;111+ if (!validatePath(relativePath)) fail(`invalid path in source manifest: ${relativePath}`);112+ const bytes = await api.downloadSourceBlob(target, entry.h);113+ if (bytes.length !== entry.s) fail(`size mismatch while downloading ${relativePath}`);114+ if (await sha256hex(bytes) !== entry.h) {115+ fail(`hash mismatch while downloading ${relativePath}`);116+ }117+ const outputPath = join(destination, ...relativePath.split("/"));118+ await makeParent(outputPath);119+ await writeFile(outputPath, bytes, overwrite);120+ downloaded++;121+ }122+ });123+ await Promise.all(workers);124+125+ return {126+ address: source.address,127+ manifestHash: source.manifestHash,128+ destination,129+ files: downloaded,130+ bytes: totalBytes,131+ };132+}
A cli/commands/download_test.ts +49 -0
@@ -0,0 +1,49 @@
1+import { type Manifest, sha256hex, type SourceResponse } from "@nzip/shared";2+import { downloadSource } from "./download.ts";3+4+function assertEquals(actual: unknown, expected: unknown): void {5+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {6+ throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);7+ }8+}9+10+Deno.test("downloadSource reconstructs each manifest file and reports the total", async () => {11+ const files = new Map<string, Uint8Array>([12+ ["index.html", new TextEncoder().encode("<h1>recovered</h1>\n")],13+ ["assets/app.js", new TextEncoder().encode("console.log('ok')\n")],14+ ]);15+ const manifest: Manifest = { v: 1, files: {} };16+ for (const [path, bytes] of files) {17+ manifest.files[path] = { h: await sha256hex(bytes), s: bytes.length, ct: "text/plain" };18+ }19+ const response: SourceResponse = { address: "2a3f", manifestHash: "a".repeat(64), manifest };20+ const byHash = new Map(Object.values(manifest.files).map((entry) => [entry.h, entry]));21+ const output = await Deno.makeTempDir();22+23+ try {24+ const result = await downloadSource(25+ {26+ source: async () => response,27+ downloadSourceBlob: async (_target, hash) => {28+ const entry = byHash.get(hash)!;29+ return files.get(Object.entries(manifest.files).find(([, f]) => f === entry)![0])!;30+ },31+ },32+ "work:demo",33+ output,34+ false,35+ );36+37+ assertEquals(await Deno.readTextFile(`${output}/index.html`), "<h1>recovered</h1>\n");38+ assertEquals(await Deno.readTextFile(`${output}/assets/app.js`), "console.log('ok')\n");39+ assertEquals(result, {40+ address: "2a3f",41+ manifestHash: "a".repeat(64),42+ destination: output,43+ files: 2,44+ bytes: 37,45+ });46+ } finally {47+ await Deno.remove(output, { recursive: true });48+ }49+});
M cli/lib/api.ts +28 -0
M cli/main.ts +5 -1
@@ -20,6 +20,7 @@
2020 import { parseArgs } from "@std/cli/parse-args";2121 import { cmdAuth } from "./commands/auth.ts";2222 import { cmdPush } from "./commands/push.ts";23+import { cmdDownload } from "./commands/download.ts";2324 import { cmdLs, cmdRevert, cmdRm, cmdShare, cmdStatus } from "./commands/sites.ts";2425 import { cmdVault } from "./commands/vault.ts";2526 import { cmdWhere } from "./commands/where.ts";
@@ -34,6 +35,7 @@ usage:
3435 nzip vault ls list vaults3536 nzip vault default <name> set default vault3637 nzip push <dir|file> [target] [--ttl 14d|30d|forever]38+ nzip download <target> [dir] [--overwrite]3739 nzip share <target> [--ttl ...] [--password PW | --no-password]3840 resolve + print url; set ttl/password3941 nzip ls [vault] list sites
@@ -54,7 +56,7 @@ to named vaults — pushes/aliases outside the list are refused (agent guardrail
5456 async function main(): Promise<void> {5557 const args = parseArgs(Deno.args, {5658 string: ["ttl", "to", "slot", "server", "token", "password"],57- boolean: ["yes", "list", "help", "no-password", "json"],59+ boolean: ["yes", "list", "help", "no-password", "overwrite", "json"],5860 alias: { h: "help", y: "yes" },5961 });6062 setJsonMode(args.json);
@@ -74,6 +76,8 @@ async function main(): Promise<void> {
7476 switch (command) {7577 case "push":7678 return await cmdPush(config, rest[0], rest[1], args.ttl);79+ case "download":80+ return await cmdDownload(config, rest[0], rest[1], args.overwrite);7781 case "share":7882 return await cmdShare(config, rest[0], args.ttl, args.password, args["no-password"]);7983 case "ls":
M deno.json +1 -1
M shared/types.ts +7 -0
M worker/src/api.ts +39 -1
@@ -3,6 +3,7 @@ import {
33 formatAddress,44 isValidName,55 manifestHash,6+ parseManifest,67 parseTarget,78 sha256hex,89 VAULT_SLOTS,
@@ -16,6 +17,7 @@ import type {
1617 PrepareRequest,1718 PrepareResponse,1819 RevertRequest,20+ SourceResponse,1921 StatusResponse,2022 Target,2123 } from "../../shared/mod.ts";
@@ -172,6 +174,15 @@ async function handleStatus(env: Env): Promise<Response> {
172174 });173175 }174176 177+/** Load the current source manifest, refusing content that is no longer hosted. */178+async function sourceManifest(env: Env, site: SiteRow): Promise<Manifest | Response> {179+ const now = Math.floor(Date.now() / 1000);180+ if (site.expires_at !== null && site.expires_at < now) return err("site expired", 410);181+ const object = await env.CONTENT.get(`manifest/${site.current_manifest}`);182+ if (!object) return err("source manifest not found", 404);183+ return parseManifest(new Uint8Array(await object.arrayBuffer()));184+}185+175186 export async function api(req: Request, env: Env, url: URL): Promise<Response> {176187 const parts = url.pathname.split("/").filter(Boolean); // ["api", ...]177188 const method = req.method;
@@ -222,11 +233,38 @@ export async function api(req: Request, env: Env, url: URL): Promise<Response> {
222233 return json(rows.map((r) => siteRowToInfo(env, r, r.vault_name)));223234 }224235 225- // sites/{target}[/revert]236+ // sites/{target}[/source[/blob-hash]|revert]226237 if (parts[1] === "sites" && parts.length >= 3) {227238 const site = await resolvePathTarget(env, parts[2]);228239 if (!site) return err("site not found", 404);229240 241+ if (parts.length === 4 && parts[3] === "source" && method === "GET") {242+ const manifest = await sourceManifest(env, site);243+ if (manifest instanceof Response) return manifest;244+ return json<SourceResponse>({245+ address: formatAddress(site.address),246+ manifestHash: site.current_manifest,247+ manifest,248+ });249+ }250+ if (parts.length === 5 && parts[3] === "source" && method === "GET") {251+ const hash = parts[4];252+ if (!HEX64.test(hash)) return err("invalid blob hash", 400);253+ const manifest = await sourceManifest(env, site);254+ if (manifest instanceof Response) return manifest;255+ const entry = Object.values(manifest.files).find((file) => file.h === hash);256+ if (!entry) return err("source blob not found", 404);257+ const blob = await env.CONTENT.get(`blob/${hash}`);258+ if (!blob) return err("source blob not found", 404);259+ return new Response(blob.body, {260+ headers: {261+ "content-type": "application/octet-stream",262+ "content-length": String(entry.s),263+ "cache-control": "no-store",264+ },265+ });266+ }267+230268 if (parts.length === 3 && method === "GET") {231269 return json(await siteRowToDetail(env, site));232270 }