clone

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

fix: preserve locked page target after unlock

Dami committed 4dcf769
commit
4dcf769b6a96058ad6f194eaadf47e429b348f54
parent
3385035e70c38f1b07758fa6fea57c8e9913c7a7
author
Dami <dami.etoile@gmail.com>
2 files changed, +150 -18
Mworker/src/serve.ts92++++++++++++++++++++++++------
Mworker/test/serve.test.mjs76++++++++++++++++++++++++-
M worker/src/serve.ts +75 -17
@@ -52,61 +52,116 @@ function publicSiteRedirect(url: string, address: string): Response {
5252 });5353 }5454 55-function unlockForm(address: string, error?: string): string {55+function escapeHtmlAttribute(value: string): string {56+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");57+}58+59+function unlockForm(address: string, returnTo: string, error?: string): string {5660 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><meta name="robots" content="noindex"><title>unlock — nzip</title>5761 <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>58-<form method="post" action="/${address}/__unlock"><b>&#128274; ${address}</b><div class="field"><input type="password" name="password" aria-label="password" placeholder="password" maxlength="256" autofocus autocomplete="current-password"><button>unlock</button></div>${62+<form method="post" action="/${address}/__unlock"><input type="hidden" name="return_to" value="${63+ escapeHtmlAttribute(returnTo)64+ }"><b>&#128274; ${address}</b><div class="field"><input type="password" name="password" aria-label="password" placeholder="password" maxlength="256" autofocus autocomplete="current-password"><button>unlock</button></div>${5965 error ? `<div class="e">${error}</div>` : ""6066 }</form>`;6167 }6268 69+function unlockReturnTarget(70+ req: Request,71+ address: string,72+ returnTo: string | null,73+): string {74+ const fallback = `/${address}`;75+ if (!returnTo) return fallback;76+77+ try {78+ const requestUrl = new URL(req.url);79+ const target = new URL(returnTo, requestUrl);80+ const siteRoot = `/${address}`;81+ if (82+ target.origin !== requestUrl.origin ||83+ (target.pathname !== siteRoot &&84+ !target.pathname.startsWith(`${siteRoot}/`)) ||85+ target.pathname === `${siteRoot}/__unlock`86+ ) {87+ return fallback;88+ }89+ return `${target.pathname}${target.search}`;90+ } catch {91+ return fallback;92+ }93+}94+6395 async function handleUnlock(6496 req: Request,6597 env: Env,6698 site: SiteRow,6799 address: string,68100 ): Promise<Response> {101+ const fallbackTarget = `/${address}`;69102 const contentLength = req.headers.get("content-length");70103 if (contentLength === null) {71- return htmlResponse(unlockForm(address, "request size required"), 411);104+ return htmlResponse(105+ unlockForm(address, fallbackTarget, "request size required"),106+ 411,107+ );72108 }73109 const declaredBytes = Number(contentLength);74110 if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0) {75- return htmlResponse(unlockForm(address, "invalid request size"), 400);111+ return htmlResponse(112+ unlockForm(address, fallbackTarget, "invalid request size"),113+ 400,114+ );76115 }77116 if (declaredBytes > MAX_UNLOCK_BODY_BYTES) {78- return htmlResponse(unlockForm(address, "request too large"), 413);117+ return htmlResponse(118+ unlockForm(address, fallbackTarget, "request too large"),119+ 413,120+ );79121 }80122 const bytes = new Uint8Array(await req.arrayBuffer());81123 if (bytes.length > MAX_UNLOCK_BODY_BYTES) {82- return htmlResponse(unlockForm(address, "request too large"), 413);124+ return htmlResponse(125+ unlockForm(address, fallbackTarget, "request too large"),126+ 413,127+ );83128 }84129 if (bytes.length !== declaredBytes) {85- return htmlResponse(unlockForm(address, "request size mismatch"), 400);130+ return htmlResponse(131+ unlockForm(address, fallbackTarget, "request size mismatch"),132+ 400,133+ );86134 }87135 const contentType = req.headers.get("content-type")?.split(";", 1)[0].trim()88136 .toLowerCase();89137 if (contentType !== "application/x-www-form-urlencoded") {90- return htmlResponse(unlockForm(address, "unsupported form"), 415);138+ return htmlResponse(139+ unlockForm(address, fallbackTarget, "unsupported form"),140+ 415,141+ );91142 }92- const password = new URLSearchParams(new TextDecoder().decode(bytes)).get(93- "password",94- );143+ const form = new URLSearchParams(new TextDecoder().decode(bytes));144+ const password = form.get("password");145+ const returnTarget = unlockReturnTarget(req, address, form.get("return_to"));95146 if (password !== null && password.length > MAX_PASSWORD_LENGTH) {96- return htmlResponse(unlockForm(address, "password too long"), 400);147+ return htmlResponse(148+ unlockForm(address, returnTarget, "password too long"),149+ 400,150+ );97151 }98152 if (99153 typeof password !== "string" || !site.password_hash ||100154 !(await verifyPassword(password, site.password_hash))101155 ) {102- return htmlResponse(unlockForm(address, "wrong password"), 401);156+ return htmlResponse(157+ unlockForm(address, returnTarget, "wrong password"),158+ 401,159+ );103160 }104161 return new Response(null, {105162 status: 303,106163 headers: {107- // Bare address: single-file sites serve there directly, multi-file ones108- // bounce through the trailing-slash redirect.109- location: `/${address}`,164+ location: returnTarget,110165 "set-cookie": await makeUnlockCookie(env, address, site.auth_version),111166 },112167 });
@@ -166,7 +221,10 @@ export async function serve(
166221 site.password_hash &&167222 !(await hasValidUnlockCookie(req, env, addressStr, site.auth_version))168223 ) {169- return htmlResponse(unlockForm(addressStr), 401);224+ return htmlResponse(225+ unlockForm(addressStr, `${url.pathname}${url.search}`),226+ 401,227+ );170228 }171229 172230 const manifestObj = await env.CONTENT.get(
M worker/test/serve.test.mjs +75 -1
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
22 import test from "node:test";33 44 import { serve } from "../src/serve.ts";5+import { hashPassword } from "../src/password.ts";56 67 const HASH = "a".repeat(64);78
@@ -119,7 +120,16 @@ test("favicon serves the wordmark PNG with a cacheable response", async () => {
119120 assert.equal(response.headers.get("cache-control"), "public, max-age=86400");120121 const bytes = new Uint8Array(await response.arrayBuffer());121122 // PNG signature: the embedded icon decoded from base64 intact.122- assert.deepEqual([...bytes.slice(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);123+ assert.deepEqual([...bytes.slice(0, 8)], [124+ 0x89,125+ 0x50,126+ 0x4e,127+ 0x47,128+ 0x0d,129+ 0x0a,130+ 0x1a,131+ 0x0a,132+ ]);123133 assert.ok(bytes.length > 500);124134 });125135
@@ -150,6 +160,70 @@ test("unlock page disables mobile zoom", async () => {
150160 assert.match(await response.text(), /maximum-scale=1, user-scalable=no/);151161 });152162 163+test("unlock returns to the requested nested page", async () => {164+ const passwordHash = await hashPassword("test");165+ const pageUrl = new URL("https://n.zip/2f9b/docs/guide.html?view=full");166+ const locked = await serve(167+ new Request(pageUrl),168+ envFor({ "docs/guide.html": html }, { password_hash: passwordHash }),169+ pageUrl,170+ );171+ const returnTo = /name="return_to" value="([^"]+)"/.exec(172+ await locked.text(),173+ )?.[1].replaceAll("&amp;", "&");174+ assert.equal(returnTo, "/2f9b/docs/guide.html?view=full");175+176+ const body = new URLSearchParams({ password: "test", return_to: returnTo });177+ const unlockUrl = new URL("https://n.zip/2f9b/__unlock");178+ const unlocked = await serve(179+ new Request(unlockUrl, {180+ method: "POST",181+ headers: {182+ "content-type": "application/x-www-form-urlencoded",183+ "content-length": String(184+ new TextEncoder().encode(body.toString()).length,185+ ),186+ },187+ body,188+ }),189+ envFor({ "docs/guide.html": html }, { password_hash: passwordHash }),190+ unlockUrl,191+ );192+193+ assert.equal(unlocked.status, 303);194+ assert.equal(195+ unlocked.headers.get("location"),196+ "/2f9b/docs/guide.html?view=full",197+ );198+ assert.match(unlocked.headers.get("set-cookie") ?? "", /^nzip_a2f9b=/);199+});200+201+test("unlock rejects a return target outside the locked site", async () => {202+ const passwordHash = await hashPassword("test");203+ const body = new URLSearchParams({204+ password: "test",205+ return_to: "https://example.com/phishing",206+ });207+ const unlockUrl = new URL("https://n.zip/2f9b/__unlock");208+ const response = await serve(209+ new Request(unlockUrl, {210+ method: "POST",211+ headers: {212+ "content-type": "application/x-www-form-urlencoded",213+ "content-length": String(214+ new TextEncoder().encode(body.toString()).length,215+ ),216+ },217+ body,218+ }),219+ envFor({ "index.html": html }, { password_hash: passwordHash }),220+ unlockUrl,221+ );222+223+ assert.equal(response.status, 303);224+ assert.equal(response.headers.get("location"), "/2f9b");225+});226+153227 test("unlock rejects an oversized declared body before password verification", async () => {154228 const url = new URL("https://n.zip/2f9b/__unlock");155229 const response = await serve(