/** * @file diigo-cache worker: the create flow, the webkey page and its API, * and public pages. See the default export for its routes. */ import { WorkerEntrypoint } from "cloudflare:workers"; import { newWorkersRpcResponse } from "capnweb"; import { CacheApi } from "./api.js"; import { CAPNWEB_BROWSER_JS } from "./capnweb-browser.js"; import { CACHE_PAGE_HTML } from "./cache-page.js"; import { DiigoCacheLogic, PublicCacheLogic } from "./cache.js"; import { PAGE_SIZE } from "./cache.js"; import type { CacheEnv, Clock, FillStatus } from "./cache.js"; import type { Item } from "./cache.js"; import { updatedISO, usernameOf } from "./items.js"; import { toWebkey, idFromWebkey, isWebkey, shashKey } from "./webkeys.js"; import { pmSave } from "./pm-save.js"; /** the live clock: the worker's entry module reads ambient time and hands * it to the cache logic, which uses only the clock it is given */ function liveClock(): Clock { return { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }; } /** * the private cache, as the runtime constructs it. DO classes must be * exports of the main module, and the runtime passes only (ctx, env): a * Cloudflare constraint, met here at the edge by supplying the live clock. */ export class DiigoCache extends DiigoCacheLogic { constructor(ctx: DurableObjectState, env: CacheEnv) { super(ctx, env, liveClock()); } } /** the public cache, as the runtime constructs it (see {@link DiigoCache}) */ export class PublicCache extends PublicCacheLogic { constructor(ctx: DurableObjectState, env: CacheEnv) { super(ctx, env, liveClock()); } } /** worker bindings (structural until wrangler types regenerates) */ type Env = { WEBAPP: { fetch(request: Request): Promise; }; CACHE_DO: DurableObjectNamespace; PUBLIC_DO: DurableObjectNamespace; DIIGO_CACHE_ID?: string; }; const UA = "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/141.0"; const DIIGO_INTERACT = "https://www.diigo.com/interact_api/"; /** the diigo export (DiigoBridge lives on; the dumpNdjson cache-id source * is TODO(2-i): ride the create form's handoff instead of a worker secret) */ export class DiigoBridge extends WorkerEntrypoint { /** bulk dump from the caller's address space: `pages * count` bookmarks * through the cache (cache-hits are storage reads) */ async dumpNdjson(pages: number, count = PAGE_SIZE): Promise { const ns = (this.env as unknown as Env).CACHE_DO; const cacheId = (this.env as unknown as Env).DIIGO_CACHE_ID; if (!cacheId) throw new Error("no cache id supplied (caller config error)"); const cache = ns.get(ns.idFromString(idFromWebkey(cacheId))); const lines: string[] = []; for (let p = 1; p <= pages; p += 1) { const page = await cache.page(p, count); lines.push(...(p === 1 ? page.items.slice(0, count) : page.items)); } return lines.join("\n"); } } /** * the worker's routes: * * - `GET /`: home ({@link home}); `POST /`: dump a page by cache id ({@link dump}) * - `GET /_connect`: webapp's mint form, relayed ({@link mintPage}); * `POST /_mint`: the mint ({@link proxyMint}) * - `POST /_wire`: wire a cache to a webapp id ({@link wire}); * `POST /_publish`: add a public companion later ({@link publishCache}) * - `GET /cache/`: the webkey page ({@link CACHE_PAGE_HTML}); * `/api`: its Cap'n Web API ({@link CacheApi}); `GET /_capnweb.js` * - `GET /public/`: public pages, read-only ({@link publicPage}) * * Cache ids are webkeys (docs/webkeys.md): they ride POST bodies and URL * fragments, never query strings or paths. */ export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const pub = url.pathname.match(/^\/public\/([A-Za-z0-9_-]+)$/); if (pub) return publicPage(request, env, pub[1]); if (url.pathname === "/_connect" && request.method === "GET") { return mintPage(env); } if (url.pathname === "/_mint" && request.method === "POST") { return proxyMint(request, env); } if (url.pathname === "/_wire" && request.method === "POST") { return wire(request, env); } if (url.pathname === "/_publish" && request.method === "POST") { return publishCache(request, env); } if (url.pathname === "/cache/" && request.method === "GET") { return new Response(CACHE_PAGE_HTML, { headers: { "content-type": "text/html;charset=utf-8" } }); } if (url.pathname === "/_capnweb.js" && request.method === "GET") { return new Response(CAPNWEB_BROWSER_JS, { headers: { "content-type": "text/javascript;charset=utf-8" } }); } if (url.pathname === "/api") { return newWorkersRpcResponse(request, new CacheApi(env)); } if (url.pathname !== "/") return new Response("not found", { status: 404 }); if (request.method === "POST") { return dump(request, env); } // GET is inert: a cache id never rides a query string return home(env, { error: "GET is inert: dumps happen on POST submits" }, null); }, }; /** * the connect page: webapp's own mint form HTML, fetched over the service * binding with diigo's prefill (base URL, UA headers) and its action * rewritten to our relay. Served here because wrangler's multi-config dev * doesn't serve an aux worker's HTTP. diigo-cache authors no fields of its * own; the init params stay editable, webapp's. */ async function mintPage(env: Env): Promise { const prefill = `?render=1&title=${encodeURIComponent("diigo cache: mint the session")}` + `&url=${encodeURIComponent(DIIGO_INTERACT)}` + `&headers=${encodeURIComponent(JSON.stringify({ "user-agent": UA, "x-requested-with": "XMLHttpRequest" }))}` + `&msPerCall=500`; let html: string; try { // a plain fetch() from inside the worker goes to the internet, not // to the service binding — binding calls need a Request handed to // the stub (workerd's entrypoint-fetch protocol) const res = await env.WEBAPP.fetch( new Request(`https://form${prefill}`, { redirect: "manual" }), ); html = await res.text(); html = html.replace('action="ACTION"', 'action="/_mint"'); } catch (err) { console.error({ event: "mint-page", error: String(err) }); return home(env, { error: "mint form unreachable (webapp worker down?)" }, null); } return new Response(html, { headers: { "content-type": "text/html;charset=utf-8" } }); } /** the mint relay: the raw form body passes through untouched into the * WEBAPP entrypoint's HTTP surface (webapp parses its own fields; the * cookie crosses browser→here→binding→webapp's DO and no code on this * side opens it) — a true conduit */ async function proxyMint(request: Request, env: Env): Promise { try { const res = await env.WEBAPP.fetch( new Request("https://mint/", { method: "POST", headers: request.headers, body: await request.arrayBuffer(), }), ); if (!res.ok) { const detail = (await res.text()).slice(0, 200); return home(env, { error: `mint said ${res.status}: ${detail}` }, null); } // webapp answers {id} (+ x-webapp-id); the id rides the header, the // wire page is ours const id = res.headers.get("x-webapp-id"); if (!id) return home(env, { error: "mint produced no id" }, null); return await wirePage(id); } catch (err) { console.error({ event: "mint", error: String(err) }); return home(env, { error: `mint failed: ${String(err)}` }, null); } } /** the credential-save helper, once per page (see ./pm-save.ts) */ const PM_SAVE_JS = pmSave.toString(); /** the suggested petname gets the holder's local hh:mm (the worker only * knows UTC), so two saves the same day are told apart at a glance */ const PETNAME_TIME_JS = `{ const p = document.getElementById("petname"); const d = new Date(); p.value += " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); }`; /** the onclick that hands pmSave its browser powers */ function pmSaveClick(value: string): string { return `pmSave(this, ${JSON.stringify(value)}, { petname: document.getElementById("petname"), credentials: navigator.credentials, PasswordCredential: window.PasswordCredential, log: console })`; } /** the wire step, with the freshly-minted webapp id pre-filled */ async function wirePage(webappId: string): Promise { const fingerprint = await nick(webappId); const body = ` diigo cache: step 2 — wire the cache

step 2: wire the cache

minted: webapp id, fingerprint ${esc(fingerprint)}

(private to you: the password-manager username; never sent to the server)

choose the cache mode, then wire — the next page shows the cache id (copy that too), after which you land back on home:

change your mind? mint again · home

`; return new Response(body, { headers: { "content-type": "text/html;charset=utf-8", "x-webapp-id": webappId, }, }); } /** * the wire step: a webapp id plus the public/private choice. The cache is * minted, wired to {webappId, publicId}, and page 1 is stored. In public * mode the diigo username is read from the fetched items (the cookie's * own account, never form input), and the public cache is wired to the * same webapp id and registered under it. No cookie arrives here. */ async function wire(request: Request, env: Env): Promise { const ct = request.headers.get("content-type") ?? ""; let webappId = ""; let mode = "private"; let wantsJson = false; try { if (ct.includes("application/json")) { const body = (await request.json()) as { webappId?: string; mode?: string }; webappId = String(body.webappId ?? ""); mode = String(body.mode ?? "private"); wantsJson = true; } else { const form = await request.formData(); webappId = String(form.get("webappId") || ""); mode = String(form.get("mode") || "private"); // a browser form POST stays on the html path; only explicit JSON // (service callers) gets the bare-JSON response wantsJson = false; } } catch (err) { return bad(`bad input: ${String(err)}`); } if (!/^[A-Za-z0-9-]+$/.test(webappId)) return bad("no webapp id supplied"); const publicId = mode === "public" ? toWebkey(env.PUBLIC_DO.newUniqueId()) : undefined; const cacheId = toWebkey(env.CACHE_DO.newUniqueId()); const cacheStub = env.CACHE_DO.get(env.CACHE_DO.idFromString(idFromWebkey(cacheId))); await cacheStub.setup({ webappId, publicId }); const first = await cacheStub.page(1, PAGE_SIZE); if (publicId) { const firstItem = first.items[0] ? (JSON.parse(first.items[0]) as Item) : undefined; const username = usernameOf(firstItem); if (!username) { // diagnostic context without item contents (privacy: items may // carry URL/titles; the count + cached flag are safe) console.error({ event: "wire", stage: "username-derive", // field names, not values: says which shape we got itemFields: firstItem ? Object.keys(firstItem) : null, itemsOnPage1: first.items.length, total: first.total, cached: first.cached, }); return bad("could not read your diigo username (u_name) from the fetched items"); } const index = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromName("index")); const pubStub = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromString(idFromWebkey(publicId))); await pubStub.setup({ webappId }); await index.register(username, publicId); if (wantsJson) return Response.json({ cacheId, publicId, username }); return wiredPage(cacheId, `public cache live at /public/${esc(username)}`, true); } if (wantsJson) { return Response.json({ cacheId, publishLater: { method: "POST", url: `${new URL(request.url).origin}/_publish`, body: { cacheId } }, }); } return wiredPage( cacheId, `private-only for now — to add the public cache later:
(or POST /_publish with cacheId in the body)`, false, ); } /** the created-cache page: ids + save/copy buttons + the pointer home */ async function wiredPage(cacheId: string, note: string, hasPublic: boolean): Promise { const fingerprint = await nick(cacheId); const body = ` diigo cache: created

cache created

cache id, fingerprint ${esc(fingerprint)}

(private to you: the password-manager username; never sent to the server)

${note}

open this cache — its webkey (bookmark it, or save the id above): fill it, watch progress${hasPublic ? ", fill its public companion" : ""}.

the cache id is the only way back to this cache — saved (above) it lives with the rest of your credentials. it is a webkey: it never rides a query string or path (those leak to history/Referer/logs). the link above carries it in the fragment, which browsers never send; the dump POST carries it in its body. the webapp id rides inside the cache's wiring; hold it separately only if you want another cache off the same session.

← home (paste the cache id into the dump form) `; return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } }); } /** a fill job's progress, as one line of page text */ function fillLine(fill: FillStatus): string { const of = `${Math.max(0, fill.nextPage - 1)} of ${fill.pages ?? "?"} pages`; const said = { running: `filling: ${of}`, done: `filled: ${of}`, stopped: `fill stopped: ${of}`, failed: `fill failed at page ${fill.nextPage}: ${fill.error ?? "unknown error"}`, }[fill.state]; return `

${esc(said)}

`; } /** * publish, later: adds a public companion to a cache wired private-only. * Its wiring already holds the webapp id, so no re-mint; the username * comes from the items already cached. The cache id rides the POST body. */ async function publishCache(request: Request, env: Env): Promise { let cacheId = ""; try { const ct = request.headers.get("content-type") ?? ""; if (ct.includes("application/json")) { const body = (await request.json()) as { cacheId?: string }; cacheId = String(body.cacheId ?? ""); } else { const form = await request.formData(); cacheId = String(form.get("cacheId") || ""); } } catch (err) { return bad(`bad input: ${String(err)}`); } if (!isWebkey(cacheId) && !/^[0-9a-fA-F]{64}$/.test(cacheId)) { return bad("no cache id supplied (POST body: cacheId)"); } try { const ns = env.CACHE_DO; const cache = ns.get(ns.idFromString(idFromWebkey(cacheId))); const wiring = await cache.wiring(); if (!wiring) { return Response.json({ error: "unknown cache id" }, { status: 404 }); } if (wiring.publicId) { return Response.json({ cacheId, publicId: wiring.publicId, status: "already published" }); } // the username comes from the items: the cookie's own account const firstPage = await cache.page(1, PAGE_SIZE); const firstItem = firstPage.items[0] ? (JSON.parse(firstPage.items[0]) as Item) : undefined; const username = usernameOf(firstItem); if (!username) { console.error({ event: "publish", stage: "username-derive", // field names, not values: says which shape we got itemFields: firstItem ? Object.keys(firstItem) : null, itemsOnPage1: firstPage.items.length, total: firstPage.total, cached: firstPage.cached, }); return Response.json( { error: "could not read your diigo username (u_name) from the cached items" }, { status: 400 }, ); } const publicId = toWebkey(env.PUBLIC_DO.newUniqueId()); const pubStub = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromString(idFromWebkey(publicId))); await pubStub.setup({ webappId: wiring.webappId }); await cache.addPublic(publicId); const index = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromName("index")); await index.register(username, publicId); return Response.json({ cacheId, publicId, username, public: `/public/${username}` }); } catch (err) { // no cacheId in the log: it is the bearer secret console.error({ event: "publish", error: String(err) }); return Response.json({ error: `publish failed: ${String(err)}` }, { status: 400 }); } } function bad(message: string): Response { return Response.json({ error: message }, { status: 400 }); } /** the private dump POST: cache id from the (client-wired) form value */ async function dump(request: Request, env: Env): Promise { const form = await request.formData(); const cacheId = String(form.get("cacheId") || ""); const raw = form.get("page"); const pageN = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN; if (!isWebkey(cacheId) && !/^[0-9a-fA-F]{64}$/.test(cacheId)) { return home(env, { error: "wire a cache id first (create flow, then copy/paste)" }, null); } if (Number.isNaN(pageN) || pageN < 1 || pageN > 10000) { return home(env, { error: "enter a page number (1 = most recently updated)" }, null); } try { const ns = env.CACHE_DO; const cache = ns.get(ns.idFromString(idFromWebkey(cacheId))); const page = await cache.page(pageN, PAGE_SIZE); const data = `total: ${page.total}\nitems: ${page.items.length}\ncache: ${page.cached ? "HIT (from storage)" : "MISS (etched now)"}` + `\n\n${page.items.join("\n")}`; return home(env, null, data, cacheId); } catch (err) { console.error({ event: "dump", page: pageN, error: String(err) }); return home(env, null, String(err), cacheId); } } const PUBLIC_PER_PAGE = PAGE_SIZE; /** * the public page for a username: stored pages only, through the cache's * read-only `viewer()` (it can't fetch or write). HTML with a page-number * form, or JSON for `Accept: application/json`. `?page=` in the URL is * fine here: nothing on this page is secret. */ async function publicPage(request: Request, env: Env, username: string): Promise { const index = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromName("index")); const publicId = await index.lookup(username); if (!publicId) return new Response("no such public cache", { status: 404 }); const ns = env.PUBLIC_DO; const cache = ns.get(ns.idFromString(idFromWebkey(publicId))); const raw = new URL(request.url).searchParams.get("page"); const pageN = raw === null ? 1 : Number.parseInt(raw, 10); const valid = !Number.isNaN(pageN) && pageN >= 1 && pageN <= 10000; const viewer = await cache.viewer(); let page, total, fill; try { page = await viewer.page(valid ? pageN : 1, PUBLIC_PER_PAGE); total = page ? page.total : await viewer.knownTotal(); fill = await viewer.fillStatus(); } catch (err) { console.error({ event: "public-page", page: pageN, error: String(err) }); return new Response(`could not load page ${valid ? pageN : 1}: ${String(err)}`, { status: 502 }); } finally { viewer[Symbol.dispose](); } // total and pages count all of diigo's items: public page n is diigo // page n with the private rows dropped const pages = total === null ? 1 : Math.max(1, Math.ceil(total / PUBLIC_PER_PAGE)); const items = page ? page.items.map((j) => JSON.parse(j) as Item) : []; const accept = request.headers.get("accept") ?? ""; if (accept.includes("application/json") && !accept.includes("text/html")) { return Response.json({ username, page: pageN, pages, total, stored: page !== null, etchedAt: page?.etchedAt ?? null, items }); } let notice = ""; if (!valid || pageN > pages) { console.log({ event: "notice", page: "public", text: "page out of range", pages }); notice = `

no page ${esc(String(raw))}: pages run 1–${pages}

`; } const here = `/public/${encodeURIComponent(username)}`; const nav = (n: number, label: string) => n >= 1 && n <= pages ? `${label}` : `${label}`; const list = notice ? "" : `
    ${items .map((it) => { const url = typeof it.url === "string" ? it.url : ""; const title = typeof it.title === "string" && it.title ? it.title : url; const desc = typeof it.description === "string" && it.description ? `
    ${esc(it.description)}` : ""; const href = /^https?:\/\//i.test(url) ? url : "#"; const when = updatedISO(it); const date = when ? `` : "(no date)"; return `
  1. ${date} ${esc(title)}${desc}
  2. `; }) .join("\n")}
`; const body = ` ${esc(username)}'s shared bookmarks

${esc(username)}'s shared bookmarks

${pages} page${pages === 1 ? "" : "s"} (diigo's, ${PUBLIC_PER_PAGE} bookmarks each; private ones are left out, so a page may show fewer).

${page ? `stored ${esc(page.etchedAt ?? "")} — page ${valid ? pageN : 1}, ${items.length} shared` : `not fetched yet — page ${valid ? pageN : 1}; the cache's owner fills it`}

${fill ? fillLine(fill) : ""}

${nav(pageN - 1, "← prev")} ${nav(pageN + 1, "next →")}

${notice}${list} `; return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } }); } /** home page: the connect link (webapp's cookie form, diigo-prefilled) + * the wire step for an already-minted id + the dump form. `home` renders * the error path too, so it takes env for the connect link. Notices (the * inert-GET explainer, probe failures) log at info — exceptional errors * log at error in their own handlers. */ async function home(env: Env, opts: { error?: string } | null, data: string | null, cacheId?: string): Promise { if (opts?.error) { console.log({ event: "notice", page: "home", text: opts.error }); } const cacheState = cacheId ? `

cache id, fingerprint ${esc(await nick(cacheId))}

` : `

no cache id wired yet — use the create flow below (or paste one).

`; const dumpForm = `
${cacheId ? `` : `

`}

`; const result = data ? `
${escapeHtml(data)}
` : opts && opts.error ? `

${escapeHtml(opts.error)}

` : ""; return new Response( ` diigo cache

diigo cache

${cacheState}

create (two steps)

  1. mint the diigo session at webapp — webapp's own mint form (diigo-prefilled); paste the Cookie header there. The cookie transits this worker only as an opaque relay into webapp's DO; nobody here opens it. Mint lands you on the wire step, id pre-filled.
  2. wire the id into a cache (mode: public|private):

the ids this mints are for you: copy them somewhere you control (password manager)

open a cache — fill it, watch progress (your password manager can fill in the cache id)

${dumpForm} ${result} `, { headers: { "content-type": "text/html;charset=utf-8" } }, ); } /** a short display name for a bearer id: a prefix of its swissHash * (identity without authority), so labels and pages never show any * glyphs of the key itself */ async function nick(id: string): Promise { return (await shashKey(id)).slice(0, 8); } function esc(s: string): string { return s .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } const escapeHtml = esc;