diigo-cache: bookmark cache in Durable Objects (goal 1c)

The dump worker becomes a page cache: a hit is served from DO SQLite
storage with no diigo call; a miss asks the webapp instance in the
cache's wiring and etches the page.

- two independent caches, one behavior (PagedCache): DiigoCache keeps
  every item; PublicCache keeps shared items only, dropping private
  rows before they are stored; served at /public/<username> (HTML page
  form, or JSON)
- create flow on one port: /_connect relays webapp's mint form, /_mint
  proxies the mint, /_wire wires a private or public cache; /_publish
  adds a public cache later
- ids are webkeys: kept out of URLs (POST bodies), shown and logged
  only as a hash fingerprint; saved to the password manager under the
  holder's petname (never sent to the server)
- items keyed on diigo's link_id; username from u_name; unkeyable
  items and every failure path are logged; each page read logs hit/miss
- tests run the real worker + DOs in the vitest pool against a fake
  webapp (tests/fake-webapp.js)

Generated-by: opencode (GLM)
Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 04:19:54 -05:00
1 parent c5e433cc9c
commit 6f409fc83a
15 files changed
+1664 -211

No files matched your search

+472 -170
View File
@@ -1,65 +1,55 @@
/**
* @file diigo-dump worker: cookie access to diigo's
* interact_api/load_user_items via a WebApp Durable Object instance from
* the webapp worker. The id is a bearer capability held in DIIGO_WEB_ID;
* the cookie lives only inside that DO's storage.
* @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.
*
* Connect flow (no askpass, no cookie-on-disk): the home page links to
* /_connect, which proxies webapp's mint form with the diigo prefills
* (cookie field blank) — paste the cookie, mint lands via the service
* binding, and the page shows the DIIGO_WEB_ID to put in .dev.vars.
* (scripts/diigo-login automates the same flow for terminals.)
* @see {@link DiigoBridge}
* 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 { dumpBookmarks, type PagedGet } from "./diigo.js";
import { DiigoCache, PublicCache } from "./cache.js";
import type { Item } from "./cache.js";
import { usernameOf } from "./items.js";
import { toWebkey, idFromWebkey, isWebkey, shashKey } from "./webkeys.js";
import { pmSave } from "./pm-save.js";
/** the WEBAPP service binding to webapp/src/index.ts:WebAppFactory; typed
* structurally here pending a shared types package */
type WebAppFactoryShape = {
newWebApp(init: {
url: string;
headers?: Record<string, string>;
msPerCall?: number;
}): Promise<{ id: string; app: PagedGet }>;
at(id: string): Promise<PagedGet>;
};
// 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 = {
DIIGO_WEB_ID?: string;
WEBAPP: WebAppFactoryShape;
WEBAPP: {
fetch(request: Request): Promise<Response>;
};
CACHE_DO: DurableObjectNamespace<DiigoCache>;
PUBLIC_DO: DurableObjectNamespace<PublicCache>;
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/";
/** prefill for webapp's mint form: everything but the cookie */
const DIIGO_PREFILL = JSON.stringify({
"user-agent": UA,
"x-requested-with": "XMLHttpRequest",
});
/** connect to the configured instance, or give the setup pointer */
async function instance(env: Env): Promise<PagedGet> {
const id = env.DIIGO_WEB_ID;
if (!id) {
throw new Error("no instance: use the connect flow on the home page, then set DIIGO_WEB_ID");
}
return env.WEBAPP.at(id);
}
/** 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 {
/** the diigo-attenuated handle: fluent chain over the instance */
open(): Promise<PagedGet> {
return instance(this.env as unknown as Env);
}
/** bulk dump from the caller's address space */
/** 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<string> {
const diigoAPI = await instance(this.env as unknown as Env);
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 await (const item of dumpBookmarks({ diigoAPI, pages, count })) {
lines.push(JSON.stringify(item));
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");
}
@@ -69,170 +59,482 @@ export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// the connect flow: proxy webapp's mint form (prefilled, cookie blank)
if (url.pathname === "/_connect") {
return env.DIIGO_WEB_ID ? connectedPage(env) : connectPage();
// 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);
}
// webapp's form POSTs back through us: forward to the binding
// 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 home(null, undefined, Boolean(env.DIIGO_WEB_ID));
const form = await request.formData();
const raw = form.get("page");
const page = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
if (Number.isNaN(page) || page < 1 || page > 10000) {
return home("enter a page number (1 = most recently updated)", undefined, Boolean(env.DIIGO_WEB_ID));
if (request.method === "POST") {
return dump(request, env);
}
try {
const app = await instance(env);
const text = await app
.pathjoin("load_user_items")
.query({ sort: "updated", count: 24, page_num: page })
.get();
const data = JSON.parse(text) as { total?: number; items?: Record<string, unknown>[] };
return home(
`total: ${data.total}\nitems: ${data.items?.length ?? 0}\n\n${JSON.stringify(data.items ?? data, null, 2)}`,
undefined,
true,
);
} catch (err) {
console.error({ event: "dump", page, error: String(err) });
return home(null, String(err), Boolean(env.DIIGO_WEB_ID));
}
// 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 form with diigo defaults. The mint POST is
* proxied (/_mint) so one port serves the whole flow; the response
* page shows the id and the .dev.vars line to add. */
async function connectPage(): Promise<Response> {
// proxy: render our own form with action="/_mint" so one port serves
// the whole flow. TODO(webapp): one canonical mint form; consider
// serving it from webapp directly.
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>connect diigo session</title></head>
<body>
<h1>connect diigo session</h1>
<ol>
<li>copy the <code>Cookie</code> header from diigo.com devtools</li>
<li>paste below, mint, then add the <code>DIIGO_WEB_ID</code> line it
shows to <code>.dev.vars</code> (deployed: <code>wrangler secret put DIIGO_WEB_ID</code>)</li>
<li>restart wrangler, dump from the <a href="/">home page</a></li>
</ol>
<form method="post" action="/_mint">
<input type="hidden" name="url" value="${esc(DIIGO_INTERACT)}">
<input type="hidden" name="headers" value="${esc(JSON.stringify({ "user-agent": UA, "x-requested-with": "XMLHttpRequest", cookie: "" }))}">
<p><label>cookie <input type="password" name="cookie" size="80" autocomplete="off"></label></p>
<p><button type="submit">mint</button></p>
</form>
</body></html>`;
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
}
async function connectedPage(env: Env): Promise<Response> {
let health = "";
/** 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<Response> {
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 {
const app = await instance(env);
// a cheap call: one item, exercises the cookie + rate gate
const text = await app
.pathjoin("load_user_items")
.query({ sort: "updated", count: 1, page_num: 1 })
.get();
const data = JSON.parse(text) as { total?: number };
health = `<p>instance is <b>live</b>: diigo reports <b>${esc(String(data.total ?? "?"))}</b> bookmarks.</p>`;
// 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: "connect-check", error: String(err) });
health = `<p>instance is configured but the last check <b>failed</b>:</p><pre>${esc(String(err))}</pre><p>If diigo expired the cookie, re-mint below.</p>`;
console.error({ event: "mint-page", error: String(err) });
return home(env, { error: "mint form unreachable (webapp worker down?)" }, null);
}
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>diigo session</title></head>
<body>
<h1>diigo session</h1>
<p>connected: id <code>${esc(`${env.DIIGO_WEB_ID!.slice(0, 6)}…${env.DIIGO_WEB_ID!.slice(-4)}`)}</code> (from .dev.vars).</p>
${health}
<h2>replace the session</h2>
<p>Only needed if the cookie is stale (diigo will say 401/403). Minting a
new instance invalidates nothing, but the id rotates and .dev.vars needs
the new <code>DIIGO_WEB_ID</code>.</p>
<form method="post" action="/_mint">
<input type="hidden" name="url" value="${esc(DIIGO_INTERACT)}">
<input type="hidden" name="headers" value="${esc(JSON.stringify({ "user-agent": UA, "x-requested-with": "XMLHttpRequest", cookie: "" }))}">
<p><label>cookie <input type="password" name="cookie" size="80" autocomplete="off"></label></p>
<p><button type="submit">mint</button></p>
</form>
</body></html>`;
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
return new Response(html, { headers: { "content-type": "text/html;charset=utf-8" } });
}
/** proxy the mint: merge the pasted cookie into the headers JSON, mint via
* the service binding, show the id (plus the .dev.vars line to copy) */
/** 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<Response> {
const form = await request.formData();
const cookie = String(form.get("cookie") || "");
if (!cookie) {
return new Response("no cookie supplied", { status: 400 });
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);
}
const headers = {
"user-agent": UA,
"x-requested-with": "XMLHttpRequest",
cookie,
};
const { id } = await env.WEBAPP.newWebApp({
url: DIIGO_INTERACT,
headers,
msPerCall: 500,
});
return idPage(id);
}
/** the id shown on the success page: a narrow text field (scrollable but
* too small to show most of the value) with a copy button for the full
* version. navigator.clipboard needs the page served from localhost/https
* — true in dev and at the deployed URL */
function idPage(id: string): Response {
/** 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<Response> {
const fingerprint = await nick(webappId);
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>minted</title></head>
<html><head><meta charset="utf-8"><title>diigo cache: step 2 — wire the cache</title></head>
<body>
<h1>minted</h1>
<p>id: <input id="wid" readonly size="4" value="${esc(id)}" onfocus="this.select()">
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(id)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p>add to <code>.dev.vars</code>:</p>
<pre>DIIGO_WEB_ID=</pre>
<p>…click copy, then paste the full value after it. <a href="/">back to diigo dump</a> (restart wrangler first).</p>
<h1>step 2: wire the cache</h1>
<p>minted: webapp id, fingerprint <code>${esc(fingerprint)}</code>
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(webappId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo webapp ${fingerprint}`)}"></label>
<small>(private to you: the password-manager username; never sent to the server)</small></p>
<script>${PETNAME_TIME_JS}</script>
<p><button type="button" onclick='${pmSaveClick(webappId)}'>save to password manager</button></p>
<p>choose the cache mode, then <b>wire</b> — the next page shows the
<b>cache id</b> (copy that too), after which you land back on home:</p>
<form method="post" action="/_wire">
<input type="hidden" name="webappId" value="${esc(webappId)}">
<p><label>mode <select name="mode"><option value="private">private</option><option value="public">public (serves /public/&lt;your-diigo-username&gt;)</option></select></label> <button type="submit">wire →</button></p>
</form>
<p>change your mind? <a href="/_connect">mint again</a> · <a href="/">home</a></p>
<script>${PM_SAVE_JS}</script>
</body></html>`;
return new Response(body, {
headers: {
"content-type": "text/html;charset=utf-8",
// for scripts: diigo-login reads this instead of scraping HTML
"x-diigo-web-id": id,
"x-webapp-id": webappId,
},
});
}
function home(detail: string | null, error?: string, connected?: boolean): Response {
const form = `<form method="post" action="/"><label>page <input type="number" name="page" min="1"></label> <button type="submit">dump</button></form>`;
const result = error
? `<p>status: <b>error</b></p><pre readonly style="white-space:pre-wrap;border:1px solid #c00;padding:1rem">${escapeHtml(error)}</pre>`
: detail
? `<pre readonly style="white-space:pre-wrap;border:1px solid #ccc;padding:1rem;max-height:60vh;overflow:auto">${escapeHtml(detail)}</pre>`
/** 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<Response> {
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 <a href="/public/${esc(encodeURIComponent(username))}">/public/${esc(username)}</a>`);
}
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:
<form method="post" action="/_publish" style="display:inline">
<input type="hidden" name="cacheId" value="${esc(cacheId)}">
<button type="submit">publish</button></form>
(or <code>POST /_publish</code> with <code>cacheId</code> in the body)`,
);
}
/** the created-cache page: ids + save/copy buttons + the pointer home */
async function wiredPage(cacheId: string, note: string): Promise<Response> {
const fingerprint = await nick(cacheId);
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache: created</title></head>
<body>
<h1>cache created</h1>
<p><b>cache id</b>, fingerprint <code>${esc(fingerprint)}</code>
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(cacheId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo cache ${fingerprint}`)}"></label>
<small>(private to you: the password-manager username; never sent to the server)</small></p>
<script>${PETNAME_TIME_JS}</script>
<p><button type="button" onclick='${pmSaveClick(cacheId)}'>save to password manager</button></p>
<p>${note}</p>
<p>the cache id is the only way back to this cache — saved (above) it lives
with the rest of your credentials. it is a <b>webkey</b>: 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.</p>
<a href="/">← home (paste the cache id into the dump form)</a>
<script>${PM_SAVE_JS}</script>
</body></html>`;
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<Response> {
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<Response> {
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<Response> {
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 = `<p><em>no page ${esc(String(raw))}: pages run 1–${pages}</em></p>`;
}
const here = `/public/${encodeURIComponent(username)}`;
const nav = (n: number, label: string) =>
n >= 1 && n <= pages ? `<a href="${here}?page=${n}">${label}</a>` : `<span>${label}</span>`;
const list = notice
? ""
: `<ol>
${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 ? `<br><small>${esc(it.description)}</small>` : "";
const href = /^https?:\/\//i.test(url) ? url : "#";
return `<li><a href="${esc(href)}">${esc(title)}</a>${desc}</li>`;
})
.join("\n")}
</ol>`;
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>${esc(username)}'s shared bookmarks</title></head>
<body>
<h1>${esc(username)}'s shared bookmarks</h1>
<p>${pages} page${pages === 1 ? "" : "s"} (diigo's, ${PUBLIC_PER_PAGE} bookmarks each; private ones are left out, so a page may show fewer).</p>
<p><strong>cache: ${page.cached ? "HIT (from storage)" : "MISS (fetched from diigo now)"}</strong> — page ${valid ? pageN : 1}, ${items.length} shared</p>
<form method="get" action="${here}">
<p>${nav(pageN - 1, "← prev")}
<label>page <input type="number" name="page" min="1" value="${Number.isNaN(pageN) ? 1 : pageN}"></label>
<button type="submit">go</button>
${nav(pageN + 1, "next →")}</p>
</form>
${notice}${list}
</body></html>`;
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<Response> {
if (opts?.error) {
console.log({ event: "notice", page: "home", text: opts.error });
}
const cacheState = cacheId
? `<p>cache id, fingerprint <code>${esc(await nick(cacheId))}</code></p>`
: `<p>no cache id wired yet — use the create flow below (or paste one).</p>`;
const dumpForm = `<form method="post" action="/">
${cacheId ? `<input type="hidden" name="cacheId" value="${esc(cacheId)}">` : `<p><label>petname <input autocomplete="username" size="24"></label>
<label>cache id <input type="password" name="cacheId" autocomplete="current-password" size="24"></label></p>`}
<p><label>page <input type="number" name="page" min="1"></label> <button type="submit">dump</button></p>
</form>`;
const result = data
? `<pre readonly style="white-space:pre-wrap;border:1px solid #ccc;padding:1rem;max-height:60vh;overflow:auto">${escapeHtml(data)}</pre>`
: opts && opts.error
? `<p><em>${escapeHtml(opts.error)}</em></p>`
: "";
return new Response(
`<!doctype html>
<html><head><meta charset="utf-8"><title>diigo dump</title></head>
<html><head><meta charset="utf-8"><title>diigo cache</title></head>
<body>
<h1>diigo dump</h1>
<p><a href="/_connect">${connected ? "diigo session status/replace…" : "connect diigo session…"}</a></p>
${form}
<h1>diigo cache</h1>
${cacheState}
<h2>create (two steps)</h2>
<ol>
<li><a href="/_connect">mint the diigo session at webapp</a> — 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.</li>
<li>wire the id into a cache (mode: public|private):</li>
</ol>
<form method="post" action="/_wire">
<p><label>petname <input autocomplete="username" size="24"></label>
<label>webapp id <input type="password" name="webappId" size="24" autocomplete="current-password"></label>
<label>mode <select name="mode"><option value="private">private</option><option value="public">public (serves /public/&lt;your-diigo-username&gt;)</option></select></label></p>
<p><button type="submit">wire</button></p>
</form>
<p>the ids this mints are for <b>you</b>: copy them somewhere you control (password manager)</p>
${dumpForm}
${result}
</body></html>`,
{ 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<string> {
return (await shashKey(id)).slice(0, 8);
}
function esc(s: string): string {
return s
.replaceAll("&", "&amp;")