clone

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

feat/site-feedback
nzip / worker / src / feedback.ts
feedback.ts 20.8K
1// Visitor feedback: opt-in, password-gated notes that a page saves to its own origin and the2// owner reads through the bearer API. Visitor text is untrusted data end to end.3import {4 FEEDBACK_ITEM_PATTERN,5 type FeedbackItem,6 type FeedbackRequestResponse,7 formatAddress,8 MAX_FEEDBACK_ITEMS,9 MAX_FEEDBACK_TEXT,10 type SiteFeedback,11} from "../../shared/mod.ts";12import { ApiError } from "./api/errors.ts";13import type { SiteRow } from "./db.ts";14import { type Env, json, siteUrl } from "./env.ts";15import { queueNotification, validateNotifyRequest } from "./notify.ts";16import { verifyPassword } from "./password.ts";1718const MAX_FEEDBACK_BODY_BYTES = 64 * 1024;19const MAX_ITEMS_PER_SAVE = 100;20const MAX_CALLBACK_URL = 2048;21const CALLBACK_TIMEOUT_MS = 10_000;2223/** Accept only absolute HTTPS URLs without embedded credentials for submit callbacks. */24function callbackUrlFrom(value: unknown): string | null {25 if (value === undefined || value === null) return null;26 if (typeof value !== "string" || value.length === 0 || value.length > MAX_CALLBACK_URL) {27 throw new ApiError(28 400,29 `callbackUrl must be an https URL of at most ${MAX_CALLBACK_URL} characters`,30 );31 }32 let url: URL;33 try {34 url = new URL(value);35 } catch {36 throw new ApiError(400, "callbackUrl must be an absolute https URL");37 }38 if (url.protocol !== "https:" || url.username !== "" || url.password !== "") {39 throw new ApiError(400, "callbackUrl must be an https URL without credentials");40 }41 return url.toString();42}4344/**45 * Tell whoever requested the round that it was submitted. Best effort with one retry; the46 * payload carries only the site and timestamps, never note text, and the URL is never logged47 * because callback URLs usually embed a bearer-like token.48 */49export async function deliverFeedbackCallback(50 callbackUrl: string,51 payload: Record<string, unknown>,52): Promise<boolean> {53 const body = JSON.stringify(payload);54 for (let attempt = 1; attempt <= 2; attempt++) {55 try {56 const response = await fetch(callbackUrl, {57 method: "POST",58 headers: { "content-type": "application/json", "user-agent": "nzip-feedback-callback" },59 body,60 redirect: "manual",61 signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS),62 });63 console.log({ event: "feedback.callback", status: response.status, attempt });64 if (response.ok) return true;65 if (response.status < 500 && response.status !== 429) return false;66 } catch (error) {67 console.log({68 event: "feedback.callback_failed",69 attempt,70 error: error instanceof Error ? error.name : "error",71 });72 }73 if (attempt === 1) await new Promise((resolve) => setTimeout(resolve, 2000));74 }75 return false;76}7778/** Feedback is only ever served behind a password gate; reject any policy that would expose it. */79export function enforceFeedbackPolicy(feedback: boolean, passwordHash: string | null): void {80 if (feedback && passwordHash === null) {81 throw new ApiError(82 400,83 "feedback requires password protection; set a password, or disable feedback first",84 );85 }86}8788/**89 * Whether a write should forget stored notes: the audience changed (a different password, or90 * protection added or removed) or feedback is being switched back on. Re-sending the same91 * password, as some wrappers do on every push, keeps the notes.92 */93export async function feedbackNeedsReset(94 site: SiteRow,95 password: string | null | undefined,96 feedback: boolean | undefined,97): Promise<boolean> {98 if (feedback === true && site.feedback !== 1) return true;99 if (password === undefined) return false;100 if (password === null || site.password_hash === null) {101 return password !== null || site.password_hash !== null;102 }103 return !(await verifyPassword(password, site.password_hash));104}105106function feedbackOpen(site: SiteRow): boolean {107 return site.feedback === 1 && site.password_hash !== null;108}109110async function currentSeq(env: Env, address: number): Promise<number> {111 const row = await env.DB.prepare(112 "SELECT COALESCE(MAX(seq), 0) AS seq FROM pushes WHERE address = ?",113 ).bind(address).first<{ seq: number }>();114 return row?.seq ?? 0;115}116117async function listFeedback(env: Env, address: number): Promise<FeedbackItem[]> {118 const rows = await env.DB.prepare(119 `SELECT item, text, seq, updated_at FROM site_feedback120 WHERE address = ? ORDER BY updated_at, item`,121 ).bind(address).all<{ item: string; text: string; seq: number; updated_at: number }>();122 return rows.results.map((row) => ({123 item: row.item,124 text: row.text,125 seq: row.seq,126 updatedAt: row.updated_at,127 }));128}129130async function feedbackState(env: Env, site: SiteRow): Promise<SiteFeedback> {131 const address = formatAddress(site.address);132 return {133 address,134 url: siteUrl(env, address),135 enabled: feedbackOpen(site),136 seq: await currentSeq(env, site.address),137 requestedAt: site.feedback_requested_at,138 submittedAt: site.feedback_submitted_at,139 callbackPending: site.feedback_callback_url !== null,140 items: await listFeedback(env, site.address),141 };142}143144/** `GET /api/sites/{target}/feedback` */145export async function handleFeedbackGet(env: Env, site: SiteRow): Promise<Response> {146 return json(await feedbackState(env, site));147}148149/** `POST /api/sites/{target}/feedback/request`: open a review round and notify the owner. */150export async function handleFeedbackRequest(151 request: Request,152 env: Env,153 site: SiteRow,154 ctx: ExecutionContext,155): Promise<Response> {156 const text = await request.text();157 let body: Record<string, unknown> = {};158 if (text.trim() !== "") {159 try {160 body = JSON.parse(text);161 } catch {162 throw new ApiError(400, "invalid JSON body");163 }164 if (!body || typeof body !== "object" || Array.isArray(body)) {165 throw new ApiError(400, "request body must be an object");166 }167 if (Object.keys(body).some((key) => key !== "message" && key !== "callbackUrl")) {168 throw new ApiError(400, "request contains unknown fields");169 }170 }171 const callbackUrl = callbackUrlFrom(body.callbackUrl);172 if (!feedbackOpen(site)) {173 throw new ApiError(174 409,175 "feedback is not enabled for this site; publish or update it with feedback enabled",176 );177 }178 const address = formatAddress(site.address);179 // Validate the notification before changing state so a bad message leaves the round untouched.180 const notification = validateNotifyRequest({181 title: "Feedback requested",182 body: body.message ?? "A page is ready for your review",183 path: `/${address}`,184 tag: `feedback-${address}`,185 });186 const now = Math.floor(Date.now() / 1000);187 await env.DB.prepare(188 `UPDATE sites SET feedback_requested_at = ?, feedback_submitted_at = NULL,189 feedback_callback_url = ? WHERE address = ?`,190 ).bind(now, callbackUrl, site.address).run();191 const queued = env.NOTIFY_ENABLED === "true"192 ? await queueNotification(env, ctx, notification)193 : null;194 const state = await feedbackState(env, {195 ...site,196 feedback_requested_at: now,197 feedback_submitted_at: null,198 feedback_callback_url: callbackUrl,199 });200 return json<FeedbackRequestResponse>({ ...state, notification: queued });201}202203function visitorJson(body: unknown, status = 200): Response {204 return json(body, status, {205 "cache-control": "no-store",206 "origin-agent-cluster": "?1",207 "x-content-type-options": "nosniff",208 });209}210211async function visitorState(env: Env, site: SiteRow) {212 const state = await feedbackState(env, site);213 return {214 ok: true,215 seq: state.seq,216 requestedAt: state.requestedAt,217 submittedAt: state.submittedAt,218 items: Object.fromEntries(219 state.items.map((item) => [item.item, {220 text: item.text,221 seq: item.seq,222 updatedAt: item.updatedAt,223 }]),224 ),225 };226}227228/** Reject cross-site writes even if a browser ever attached the Lax unlock cookie. */229function sameOriginWrite(req: Request, url: URL): boolean {230 const origin = req.headers.get("origin");231 if (origin !== null && origin !== url.origin) return false;232 const fetchSite = req.headers.get("sec-fetch-site");233 if (fetchSite !== null && fetchSite !== "same-origin") return false;234 return req.headers.get("content-type")?.split(";", 1)[0].trim() === "application/json";235}236237function parseSave(raw: unknown): { items: Map<string, string>; submit: boolean } {238 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {239 throw new ApiError(400, "request body must be an object");240 }241 const record = raw as Record<string, unknown>;242 if (Object.keys(record).some((key) => key !== "items" && key !== "submit")) {243 throw new ApiError(400, "request contains unknown fields");244 }245 if (record.submit !== undefined && typeof record.submit !== "boolean") {246 throw new ApiError(400, "submit must be a boolean");247 }248 const items = new Map<string, string>();249 if (record.items !== undefined) {250 if (!record.items || typeof record.items !== "object" || Array.isArray(record.items)) {251 throw new ApiError(400, "items must map item keys to text");252 }253 const entries = Object.entries(record.items as Record<string, unknown>);254 if (entries.length > MAX_ITEMS_PER_SAVE) {255 throw new ApiError(400, `at most ${MAX_ITEMS_PER_SAVE} items per save`);256 }257 for (const [item, text] of entries) {258 if (!FEEDBACK_ITEM_PATTERN.test(item)) throw new ApiError(400, `invalid item key: ${item}`);259 if (typeof text !== "string") throw new ApiError(400, `item ${item} must be text`);260 if ([...text].length > MAX_FEEDBACK_TEXT) {261 throw new ApiError(400, `item ${item} exceeds ${MAX_FEEDBACK_TEXT} characters`);262 }263 items.set(item, text);264 }265 }266 return { items, submit: record.submit === true };267}268269/** Save notes; on submit, returns the one-shot callback URL that this submit claimed, if any. */270async function saveFeedback(271 req: Request,272 env: Env,273 site: SiteRow,274): Promise<string | null> {275 const declared = Number(req.headers.get("content-length") ?? "0");276 if (Number.isFinite(declared) && declared > MAX_FEEDBACK_BODY_BYTES) {277 throw new ApiError(413, "request too large");278 }279 const text = await req.text();280 if (new TextEncoder().encode(text).byteLength > MAX_FEEDBACK_BODY_BYTES) {281 throw new ApiError(413, "request too large");282 }283 let raw: unknown;284 try {285 raw = JSON.parse(text);286 } catch {287 throw new ApiError(400, "invalid JSON body");288 }289 const { items, submit } = parseSave(raw);290291 const existing = await env.DB.prepare("SELECT item FROM site_feedback WHERE address = ?")292 .bind(site.address).all<{ item: string }>();293 const kept = new Set(existing.results.map((row) => row.item));294 for (const [item, value] of items) {295 if (value.trim() === "") kept.delete(item);296 else kept.add(item);297 }298 if (kept.size > MAX_FEEDBACK_ITEMS) {299 throw new ApiError(400, `a site keeps at most ${MAX_FEEDBACK_ITEMS} feedback items`);300 }301302 const now = Math.floor(Date.now() / 1000);303 const seq = await currentSeq(env, site.address);304 // Deletes first, so clearing a note frees room for a new one in the same save.305 const ordered = [...items].sort(([, a], [, b]) =>306 Number(a.trim() !== "") - Number(b.trim() !== "")307 );308 const statements = ordered.map(([item, value]) =>309 value.trim() === ""310 ? env.DB.prepare("DELETE FROM site_feedback WHERE address = ? AND item = ?")311 .bind(site.address, item)312 : env.DB.prepare(313 `INSERT INTO site_feedback (address, item, text, seq, updated_at) VALUES (?, ?, ?, ?, ?)314 ON CONFLICT(address, item) DO UPDATE SET315 text = excluded.text, seq = excluded.seq, updated_at = excluded.updated_at`,316 ).bind(site.address, item, value, seq, now)317 );318 // Read and clear the callback in the same transaction so concurrent submits fire it once.319 const claim = statements.length;320 if (submit) {321 statements.push(322 env.DB.prepare("SELECT feedback_callback_url FROM sites WHERE address = ?")323 .bind(site.address),324 env.DB.prepare(325 "UPDATE sites SET feedback_submitted_at = ?, feedback_callback_url = NULL WHERE address = ?",326 ).bind(now, site.address),327 );328 }329 if (statements.length === 0) return null;330 try {331 const results = await env.DB.batch(statements);332 if (!submit) return null;333 const row = results[claim].results[0] as { feedback_callback_url: string | null } | undefined;334 return row?.feedback_callback_url ?? null;335 } catch (error) {336 // The cap trigger catches saves that raced past the check above.337 if (String(error).includes("feedback item limit reached")) {338 throw new ApiError(400, `a site keeps at most ${MAX_FEEDBACK_ITEMS} feedback items`);339 }340 throw error;341 }342}343344/**345 * Serve the reserved `/__feedback` routes on an unlocked site origin, or return null for any346 * other path. The caller has already applied the password gate.347 */348export async function serveSiteFeedback(349 req: Request,350 env: Env,351 url: URL,352 site: SiteRow,353 ctx: ExecutionContext,354): Promise<Response | null> {355 if (url.pathname === "/__feedback.js") {356 if (req.method !== "GET" && req.method !== "HEAD") {357 return visitorJson({ error: "method not allowed" }, 405);358 }359 return new Response(req.method === "HEAD" ? null : FEEDBACK_SCRIPT, {360 headers: {361 "content-type": "text/javascript; charset=utf-8",362 "cache-control": site.password_hash ? "private, no-store" : "public, max-age=300",363 "origin-agent-cluster": "?1",364 "x-content-type-options": "nosniff",365 },366 });367 }368 if (url.pathname !== "/__feedback") return null;369 if (!feedbackOpen(site)) {370 return visitorJson({ error: "feedback is not enabled for this site" }, 404);371 }372 try {373 if (req.method === "GET" || req.method === "HEAD") {374 return visitorJson(await visitorState(env, site));375 }376 if (req.method !== "POST") return visitorJson({ error: "method not allowed" }, 405);377 if (!sameOriginWrite(req, url)) {378 return visitorJson({ error: "feedback must be sent as same-origin JSON" }, 403);379 }380 const callbackUrl = await saveFeedback(req, env, site);381 const fresh = await env.DB.prepare("SELECT * FROM sites WHERE address = ?")382 .bind(site.address).first<SiteRow>();383 const state = await visitorState(env, fresh ?? site);384 if (callbackUrl) {385 const address = formatAddress(site.address);386 ctx.waitUntil(deliverFeedbackCallback(callbackUrl, {387 event: "nzip.feedback.submitted",388 address,389 url: siteUrl(env, address),390 requestedAt: state.requestedAt,391 submittedAt: state.submittedAt,392 items: Object.keys(state.items).length,393 }));394 }395 return visitorJson(state);396 } catch (error) {397 if (error instanceof ApiError) return visitorJson({ error: error.message }, error.status);398 throw error;399 }400}401402/**403 * Page helper served at `/__feedback.js`. Pages mark fields with `data-nzip-feedback="<item>"`,404 * an optional `data-nzip-feedback-submit` button, and an optional `data-nzip-feedback-status`405 * element. Notes autosave, restore on reload, and notes from an earlier review round appear as406 * placeholders. Custom UIs can use `window.nzipFeedback` and the `nzip-feedback` event.407 * Kept free of backticks and dollar-brace sequences so it can live in this template literal.408 */409export const FEEDBACK_SCRIPT = `(() => {410 "use strict";411 var ENDPOINT = "/__feedback";412 var DELAY = 1200;413 var KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;414 var MAX_TEXT = 4000;415 var pending = {};416 var timer = 0;417 var state = null;418 var off = false;419 var has = Object.prototype.hasOwnProperty;420421 function all(selector) { return Array.prototype.slice.call(document.querySelectorAll(selector)); }422 function fields() { return all("[data-nzip-feedback]"); }423 function keyOf(el) { return el.getAttribute("data-nzip-feedback"); }424 function status(text, kind) {425 all("[data-nzip-feedback-status]").forEach(function (el) {426 el.textContent = text;427 el.setAttribute("data-state", kind || "");428 });429 }430 function emit() {431 document.dispatchEvent(new CustomEvent("nzip-feedback", { detail: state }));432 }433 function note(key) {434 return state && state.items && has.call(state.items, key) ? state.items[key] : null;435 }436 function valueOf(key) {437 var group = fields().filter(function (el) { return keyOf(el) === key; });438 for (var i = 0; i < group.length; i++) {439 var el = group[i];440 if (el.type === "radio") { if (el.checked) return el.value; continue; }441 if (el.type === "checkbox") return el.checked ? (el.value || "yes") : "";442 return "value" in el ? el.value : el.textContent;443 }444 return "";445 }446 function current(item) {447 return item && (!state.requestedAt || item.updatedAt >= state.requestedAt);448 }449 function fill() {450 fields().forEach(function (el) {451 var key = keyOf(el);452 if (has.call(pending, key)) return; // keep what the reviewer typed while loading453 var item = note(key);454 var text = current(item) ? item.text : "";455 if (el.type === "radio") el.checked = text !== "" && el.value === text;456 else if (el.type === "checkbox") el.checked = text !== "";457 else if ("value" in el) {458 el.value = text;459 if (item && !current(item) && "placeholder" in el) el.placeholder = "Earlier note: " + item.text;460 }461 });462 }463 function call(body, keepalive) {464 var init = { credentials: "same-origin", cache: "no-store" };465 if (body) {466 init.method = "POST";467 init.headers = { "content-type": "application/json" };468 init.body = JSON.stringify(body);469 init.keepalive = !!keepalive;470 }471 return fetch(ENDPOINT, init).then(function (res) {472 return res.json().catch(function () { return {}; }).then(function (data) {473 if (res.status === 404) { off = true; throw new Error("Feedback is off for this page"); }474 if (!res.ok) {475 var error = new Error(data.error || "Could not save feedback");476 error.retry = res.status === 429 || res.status >= 500;477 throw error;478 }479 state = data;480 emit();481 return data;482 });483 }, function (error) {484 error.retry = true; // network failure485 throw error;486 });487 }488 function flush(submit, keepalive) {489 clearTimeout(timer);490 var items = pending;491 pending = {};492 if (!submit && !Object.keys(items).length) return Promise.resolve(state);493 status(submit ? "Sending..." : "Saving...", "busy");494 return call({ items: items, submit: !!submit }, keepalive).then(function (data) {495 status(submit ? "Feedback sent" : "Saved", submit ? "sent" : "saved");496 return data;497 }, function (error) {498 // Only transient failures are retried; a rejected save must not block later ones.499 if (error.retry) {500 Object.keys(items).forEach(function (key) { if (!has.call(pending, key)) pending[key] = items[key]; });501 }502 status(error.message, "error");503 throw error;504 });505 }506 function save(key, text) {507 if (off) return false;508 if (!KEY.test(key || "")) {509 console.warn("nzip feedback: skipping invalid item key", key);510 return false;511 }512 if (Array.from(text).length > MAX_TEXT) {513 status("Notes are limited to " + MAX_TEXT + " characters", "error");514 return false;515 }516 pending[key] = text;517 status("Unsaved changes", "dirty");518 clearTimeout(timer);519 timer = setTimeout(function () { flush(false).catch(function () {}); }, DELAY);520 return true;521 }522 function onInput(event) {523 var el = event.target && event.target.closest && event.target.closest("[data-nzip-feedback]");524 if (el) save(keyOf(el), valueOf(keyOf(el)));525 }526 function submit() {527 if (off) return Promise.reject(new Error("Feedback is off for this page"));528 return flush(true); // pending edits plus the submit mark; saved notes stay as they are529 }530531 var ready = call(null).then(function (data) {532 fill();533 // A feedback request clears submittedAt, so any value here belongs to the current round.534 status(data.submittedAt ? "Feedback sent" : "", data.submittedAt ? "sent" : "");535 return data;536 }, function (error) {537 off = true;538 var local = location.protocol === "file:" || error.retry;539 status(local ? "Feedback saves once this page is published on nzip" : error.message, "off");540 all("[data-nzip-feedback-submit]").forEach(function (el) { el.disabled = true; });541 return null;542 });543544 document.addEventListener("input", onInput);545 document.addEventListener("change", onInput);546 document.addEventListener("click", function (event) {547 var button = event.target && event.target.closest && event.target.closest("[data-nzip-feedback-submit]");548 if (!button) return;549 event.preventDefault();550 submit().catch(function () {});551 });552 addEventListener("pagehide", function () {553 if (!off && Object.keys(pending).length) flush(false, true).catch(function () {});554 });555556 window.nzipFeedback = {557 ready: ready,558 save: function (key, text) { return save(key, text) ? flush(false) : Promise.reject(new Error("Not saved")); },559 submit: submit,560 get state() { return state; },561 };562})();563`;