diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index 081e109..90b2fba 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -31,6 +31,55 @@ function markSeen() { refreshBadges() } +// PoW (easypow, docs/messages.md): the server issues a fresh single-use +// challenge with every registration/resync and after every accepted +// message; one solution burns one message. The solver module is served +// by the backend from the easypow package, so both sides always match. +const SOLVER_URL = '/_contact/easypow.js' // backend-served; kept in a variable so Rollup leaves the import dynamic +let bridge = null +let token = '' +let solverPromise = null +let powSolution = null // Promise solving the current challenge +let powAborter = null +let unsent = null // last message text, retried once if its solution was rejected + +// The solution for the currently outstanding challenge; rides out a +// challenge replacement (reconnect) by continuing with the new solve. +async function currentSolution() { + for (;;) { + const p = powSolution + if (!p) return '' + try { + return await p + } catch { + if (p === powSolution) return '' // failed without a replacement + } + } +} + +async function sendMessage(md) { + unsent = md + const pow = await currentSolution() + if (pow) bridge.send({ token, msg: md, pow }) +} + +async function onPow(msg) { + powAborter?.abort() // superseded challenge: stop the stale solve + const ac = (powAborter = new AbortController()) + solverPromise ??= import(/* @vite-ignore */ SOLVER_URL) + const { solve } = await solverPromise + powSolution = solve(msg.challenge, ac.signal) + powSolution.catch(() => {}) // aborts are expected + // A rejected send (stale or already burned solution) is retried once + // the fresh challenge is solved; a second rejection drops it. + if (msg.required && unsent) { + const md = unsent + unsent = null + const pow = await currentSolution() + if (pow) bridge.send({ token, msg: md, pow }) + } +} + // A "new reply" dot on every contact trigger button while the panel is // closed and the newest admin message is younger than the last look. // Called on thread pushes and (from pagerite.js) after navigations, @@ -49,13 +98,14 @@ function onThread(thread) { else refreshBadges() } -export function open(bridge) { +export function open(br) { + bridge = br if (!app) { - const token = ensureToken() + token = ensureToken() app = createApp(ContactPanel, { state, token, - onSend: (md) => bridge.send({ token, msg: md }), + onSend: (md) => sendMessage(md), onClose: () => { state.open = false refreshBadges() @@ -67,7 +117,7 @@ export function open(bridge) { app.mount(el) // Registration doubles as the resync: the server pushes the thread // state back, and pagerite.js re-registers on every socket reconnect. - bridge.register(token, onThread) + bridge.register(token, onThread, onPow) } state.open = true markSeen() diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index c9b17cd..bdec7db 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -643,6 +643,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; return; } if (msg?.type === "thread") contactListener(msg); + else if (msg?.type === "pow") contactPowListener?.(msg); else if (msg?.type === "contact-error") console.warn("contact:", msg.detail); }; ws.onclose = () => { @@ -893,10 +894,12 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; let contactMod = null; let contactToken = ""; let contactListener = null; + let contactPowListener = null; const contactBridge = { - register(token, onThread) { + register(token, onThread, onPow) { contactToken = token; contactListener = onThread; + contactPowListener = onPow; contactBridge.send({ token }); }, send(msg) {