Comments written for the reader: no restating what the shebang says, no traces of the design conversation; the publish comment no longer mentions a projection that no longer exists. Generated-by: Claude Opus 5.5
591 lines
25 KiB
TypeScript
591 lines
25 KiB
TypeScript
/**
|
||
* @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<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 = PAGE_SIZE): 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");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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/<username>`: 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<Response> {
|
||
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<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/<your-diigo-username>)</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: 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<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, 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 <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 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>
|
||
<p><a href="/cache/#${esc(cacheId)}">open this cache</a> — its <b>webkey</b>
|
||
(bookmark it, or save the id above): fill it, watch progress${hasPublic ? ", fill its public companion" : ""}.</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 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.</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>`;
|
||
}
|
||
|
||
/**
|
||
* 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<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" });
|
||
}
|
||
// 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<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, 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<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;
|
||
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 = `<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>${page ? `<strong>stored ${esc(page.etchedAt ?? "")}</strong> — page ${valid ? pageN : 1}, ${items.length} shared` : `<strong>not fetched yet</strong> — page ${valid ? pageN : 1}; the cache's owner fills it`}</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/<your-diigo-username>)</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>
|
||
<p><a href="/cache/">open a cache</a> — fill it, watch progress (your password manager can fill in the cache id)</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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
const escapeHtml = esc; |