/** * @file diigo-cache worker: caches diigo bookmark dumps in Durable-Object * SQLite storage. `PrivateCache` (public+private items) is addressed by its * own unique id — minted by the create form alongside the webapp instance * (the cookie lives in that DO's storage); `PublicCache` (public-only * items) is widely shared, addressed by the diigo username in the path. * Dump output stays raw; a repeat page load is answered from storage with * no diigo call. * * The create form's ids reach the browser only via explicit user action * (clipboard copy / file save — the poor-man's powerbox); a pasted id is * how the home page's dump POST knows which cache to hit. */ import { WorkerEntrypoint } from "cloudflare:workers"; import { DiigoCache, PublicCache } 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 DO classes must be entrypoint exports (the exports/ binding targets // the main module) export { DiigoCache, PublicCache }; /** 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 = 24): 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"); } } export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); // public cache: widely shared, username in the path const pub = url.pathname.match(/^\/public\/([A-Za-z0-9_-]+)$/); if (pub) return publicPage(request, env, pub[1]); // webapp's mint form, relayed onto our port (wrangler multi-config // dev doesn't mount an aux HTTP server): diigo-cache supplies only // the diigo prefill (base URL + UA headers) and the form HTML is // webapp's own — its init params (url, headers, msPerCall) are // editable there; the POST relays opaquely (a conduit: the cookie // crosses, no code on this side opens/logs it). if (url.pathname === "/_connect" && request.method === "GET") { return mintPage(env); } // the mint proxied: form fields (incl. the cookie) relay to // WEBAPP.newWebApp; the response is the wire form, webapp id filled if (url.pathname === "/_mint" && request.method === "POST") { return proxyMint(request, env); } // the wire step: mint sent us back the webapp id (form-encoded from // the mint form's hidden-continue, or JSON service-style). No cookie // ever arrives here. if (url.pathname === "/_wire" && request.method === "POST") { return wire(request, env); } // publish a cache that was wired private-only: adds the public // companion (derived from the private cache's stored wiring — the // webapp id rides along, no re-mint), registers the username read // from its items, seeds the projection. The cache id is a webkey: it // rides the POST body, never the path. if (url.pathname === "/_publish" && request.method === "POST") { return publishCache(request, env); } if (url.pathname !== "/") return new Response("not found", { status: 404 }); if (request.method === "POST") { return dump(request, env); } // GET / is inert (dumps happen on POST submits). The cache id is a // webkey: it never rides the URL (query strings leak into history/ // Referer/logs — see docs/webkeys.md); the wired form POST is how // it travels. 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, action rewritten to our relay), carried with diigo's * prefill only — the editable init params are webapp's, diigo-cache * authors no form fields of its own */ 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: webapp-instance id + the public/private choice. The * cache is minted, wired to {webappId, publicId}, and page 1 is etched; * in public mode the diigo username is read from the fetched items * (self-authenticating: the cookie's account, never form input) and the * public cache is wired to the same webapp id and registered. The two * caches are independent from then on: each fills itself on a miss. */ 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, 24); 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)}`); } 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)`, ); } /** the created-cache page: ids + save/copy buttons + the pointer home */ async function wiredPage(cacheId: string, note: string): 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}

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 travels in URLs (query strings leak to history/Referer/logs); the dump POST carries it in its body instead. 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" } }); } /** publish, later: the private cache's wiring already holds the webapp * id, so the public companion needs no re-mint; the username comes from * the items already in the cache */ 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" }); } // seed the projection *now*, while the cache is still private-only: // only rows already cached and marked shared can enter the public // side; the username rides the same rows const firstPage = await cache.page(1, 24); 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, 24); 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 = 24; /** the public page for a username: an HTML page with a page-number form * (GET — nothing secret here, so `?page=` in the URL is fine, unlike the * webkey routes), or the same page as JSON for `Accept: application/json` * callers */ 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; let page; try { // a hit is storage-only; a miss asks the webapp (private rows are // dropped before etching) page = await cache.page(valid ? pageN : 1, PUBLIC_PER_PAGE); } 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 }); } // total and pages count all of diigo's items: public page n is diigo // page n with the private rows dropped const pages = Math.max(1, Math.ceil(page.total / PUBLIC_PER_PAGE)); const items = 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: page.total, 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).

cache: ${page.cached ? "HIT (from storage)" : "MISS (fetched from diigo now)"} — page ${valid ? pageN : 1}, ${items.length} shared

${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)

${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;