Files
captoys/diigo-cache/src/index.ts
T
dckc 31ded9735b diigo-cache: fill job — a button that grabs every page (goal 1e)
POST /_fill (cache id in the body; target private|public; action
start|stop) starts or stops a background walk of every diigo page into
the cache. DO alarms advance it pagesPerStep pages per step (default
20), a second apart; progress (FillStatus) is stored in the DO after
each page, so it survives eviction. Cached pages are skipped; starting
while running rejoins the job; a stop mid-step wins; a failure is
recorded with its reason and logged once. The created page gets fill
buttons; the public page shows "filling: n of m pages".

Fill state is new, additive DO storage (fill, fillStep): no existing
state changes meaning.

The 13 fill specs are unskipped and green, plus one: a fill makes one
diigo call per page fetched.

Generated-by: Claude Opus 5.5
2026-10-07 04:25:38 -05:00

631 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 { 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 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<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/";
/** 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<string> {
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<Response> {
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);
}
// the fill job (goal 1e): owner-only, cache id in the body
if (url.pathname === "/_fill" && request.method === "POST") {
return fillRoute(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<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 {
// 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<Response> {
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<Response> {
const fingerprint = await nick(webappId);
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache: step 2 — wire the cache</title></head>
<body>
<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",
"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<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>`, 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:
<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)`,
false,
);
}
/** the fill job's buttons: one per cache the holder can fill (the id
* rides a hidden field, never the URL) */
function fillForms(cacheId: string, hasPublic: boolean): string {
const form = (target: string, label: string) => `<form method="post" action="/_fill">
<input type="hidden" name="cacheId" value="${esc(cacheId)}">
<input type="hidden" name="target" value="${target}">
<button type="submit">${label}</button></form>`;
return `<p>fetch every page now, in the background (a few minutes for thousands of bookmarks):</p>
${form("private", "fill the private cache")}
${hasPublic ? form("public", "fill the public cache") : ""}`;
}
/** the created-cache page: ids + save/copy buttons + the pointer home */
async function wiredPage(cacheId: string, note: string, hasPublic: boolean): 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>
${fillForms(cacheId, hasPublic)}
<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" } });
}
/** 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 `<p>${esc(said)}</p>`;
}
/** POST /_fill: start or stop the fill job of the holder's private cache
* (target=private, the default) or of its public companion
* (target=public). JSON in → JSON out; a form post gets a small page. */
async function fillRoute(request: Request, env: Env): Promise<Response> {
const ct = request.headers.get("content-type") ?? "";
const wantsJson = ct.includes("application/json");
let fields: Record<string, string>;
try {
fields = wantsJson
? Object.fromEntries(Object.entries((await request.json()) as Record<string, unknown>).map(([k, v]) => [k, String(v ?? "")]))
: Object.fromEntries([...(await request.formData()).entries()].map(([k, v]) => [k, String(v)]));
} catch (err) {
return bad(`bad input: ${String(err)}`);
}
const { cacheId = "", target = "private", action = "start" } = fields;
if (!isWebkey(cacheId)) return bad("no cache id supplied (POST body: cacheId)");
if (target !== "private" && target !== "public") return bad("target: expected private or public");
if (action !== "start" && action !== "stop") return bad("action: expected start or stop");
const cache = env.CACHE_DO.get(env.CACHE_DO.idFromString(idFromWebkey(cacheId)));
const wiring = await cache.wiring();
if (!wiring) return Response.json({ error: "unknown cache id" }, { status: 404 });
let stub: { startFill(): Promise<FillStatus>; stopFill(): Promise<FillStatus> } = cache;
if (target === "public") {
if (!wiring.publicId) return bad("this cache has no public companion; publish it first");
stub = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromString(idFromWebkey(wiring.publicId)));
}
let status: FillStatus;
try {
status = action === "start" ? await stub.startFill() : await stub.stopFill();
} catch (err) {
console.error({ event: "fill", target, action, error: String(err) });
return bad(`could not ${action} the fill: ${String(err)}`);
}
const code = action === "start" ? 202 : 200;
if (wantsJson) return Response.json(status, { status: code });
return new Response(
`<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache: fill</title></head>
<body>
<h1>fill (${esc(target)} cache)</h1>
${fillLine(status)}
<p>it runs in the background; the progress shows on the public page, or post this form again to check.</p>
<a href="/">← home</a>
</body></html>`,
{ status: code, 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 fill = await cache.fillStatus();
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 : "#";
const when = updatedISO(it);
const date = when ? `<time datetime="${when}">${when}</time>` : "(no date)";
return `<li>${date} <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>
${fill ? fillLine(fill) : ""}
<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 cache</title></head>
<body>
<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;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
const escapeHtml = esc;