contact messages: client proof flow, upload re-auth, trail ✉️

The contact bundle submits each solved challenge immediately and treats
{quota} frames as the grant that releases queued sends/uploads; an
upload answered 429 "pow" re-registers for a fresh challenge and
retries once it lands (Composer's reauthorize prop). The contact email
frame fills the dialog's mailto link only after a solve. Visit trails
render the ✉️ marker on the step where the panel was opened and the
proof solved.
This commit is contained in:
LeoVasanko committed 2026-09-28 14:39:49 +00:00
1 parent 7fd73da933
commit 2843469e04
7 files changed
+105 -40

No files matched your search

+10 -1
View File
@@ -18,6 +18,9 @@ const props = defineProps({
// kept — e.g. the contact panel's first-message name/email fields are
// still empty.
sendDisabled: { type: Boolean, default: false },
// Visitor side: an upload hit the end of the PoW quota (429 "pow") —
// re-prove and resolve, then the upload retries once.
reauthorize: { type: Function, default: null },
})
const emit = defineEmits(['send'])
@@ -86,7 +89,13 @@ async function uploadFiles(files) {
const path = `/_${props.token ? 'contact' : 'api'}/files/${encodeURIComponent(file.name)}`
const init = { method: 'PUT', body: file }
if (props.token) init.headers = { 'x-contact-token': props.token }
const r = await (props.token ? fetch(path, init) : apiFetch(path, init))
const put = () => (props.token ? fetch(path, init) : apiFetch(path, init))
let r = await put()
if (!r.ok && r.status === 429 && props.reauthorize) {
// The PoW quota (shared with messages) ran out: re-prove, retry once.
const detail = await r.json().then((d) => d?.detail, () => null)
if (detail === 'pow' && (await props.reauthorize())) r = await put()
}
if (!r.ok) {
const detail = await r.json().then((d) => d?.detail, () => null)
throw new Error(detail || `status ${r.status}`)
+9 -1
View File
@@ -12,6 +12,8 @@ import MessagesView from './MessagesView.vue'
const props = defineProps({
state: { type: Object, required: true },
token: { type: String, required: true },
// Visitor-side PoW quota re-grant for the composer's upload retries.
reauthorize: { type: Function, default: null },
})
const emit = defineEmits(['send', 'close'])
@@ -45,7 +47,13 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<a v-if="!admin && state.email" class="mailto" :href="mailto">{{ state.email }}</a>
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
</header>
<ContactPanel v-if="!admin" :state="state" :token="token" @send="(p) => emit('send', p)" />
<ContactPanel
v-if="!admin"
:state="state"
:token="token"
:reauthorize="reauthorize"
@send="(p) => emit('send', p)"
/>
<MessagesView v-else :state="state" />
</section>
</div>
+10 -1
View File
@@ -11,6 +11,9 @@ import Composer from './Composer.vue'
const props = defineProps({
state: { type: Object, required: true },
token: { type: String, required: true },
// Re-grant the PoW quota (an upload hit its end): resolves once the
// fresh proof is accepted, so the upload can retry.
reauthorize: { type: Function, default: null },
})
const emit = defineEmits(['send'])
@@ -81,7 +84,13 @@ function onSend(md) {
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100">
<input v-model="intro.email" type="email" placeholder="Your email (optional)" maxlength="200">
</div>
<Composer :token="token" :send-disabled="introInvalid" autofocus @send="onSend" />
<Composer
:token="token"
:send-disabled="introInvalid"
:reauthorize="reauthorize"
autofocus
@send="onSend"
/>
</template>
<style scoped>
+10
View File
@@ -24,6 +24,9 @@ const title = computed(() => {
if (hasError.value) {
parts.push(`${props.step.status}`)
}
if (props.step.contact) {
parts.push('contact panel opened, proof of work solved')
}
return parts.filter(Boolean).join(' — ')
})
</script>
@@ -39,6 +42,7 @@ const title = computed(() => {
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
<img v-if="favicon" class="favicon" :src="favicon" alt="" />
<span>{{ step.slug }}</span>
<span v-if="step.contact" class="contact-mark">✉️</span>
<span v-for="(f, fi) in flags" :key="fi" class="flag" v-html="f"></span>
</a>
</template>
@@ -51,6 +55,12 @@ const title = computed(() => {
vertical-align: -0.1em;
}
/* The contact-panel marker (PoW solved on this step). */
.contact-mark {
margin-left: 0.25em;
font-size: 0.85em;
}
/* Same flag chip as the visitor cells (VisitorCell.vue). */
.flag {
display: inline-flex;
+3
View File
@@ -656,6 +656,8 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
if (item.read) step.readSeconds = item.read
if (item.status) step.status = item.status
if (item.lang) step.lang = item.lang
// Contact panel opened here and its PoW solved (✉️ marker).
if (item.contact) step.contact = true
}
return step
})
@@ -667,6 +669,7 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang)
if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds
if (step.status) prev.status = step.status
if (step.contact) prev.contact = true
} else {
step.langs = step.lang ? [step.lang] : []
trail.push(step)
+59 -36
View File
@@ -50,7 +50,8 @@ function readContactStrings() {
}
state.label = assets['pagerite:contact-label'] || 'Contact us'
state.subject = assets['pagerite:contact-subject'] || state.label
state.email = assets['pagerite:contact-email'] || ''
// state.email is NOT reset here: it arrives over the socket after the
// PoW is solved and stays valid for the session.
}
// The panel is main content, not a sidebar: while open it holds paskia's
@@ -83,52 +84,72 @@ function markSeen() {
}
// 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.
// challenge on registration/resync and whenever the quota runs out. The
// solver runs in the background and the solution is submitted as soon as
// it is ready; a validated proof grants a quota of messages and uploads
// and reveals the site's contact email (never carried in the page's
// asset config). 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<string> solving the current challenge
let powAborter = null
let unsent = null // last message frame, retried once if its solution was rejected
let unsent = null // last message frame, retried once when its send races the quota
let quotaWaiters = [] // uploads waiting for a re-granted quota
// 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
// The solved solution goes out immediately, ahead of any send.
async function submitProof() {
const p = powSolution
if (!p) return
try {
const pow = await p
if (pow && p === powSolution) bridge.send({ token, pow })
} catch { /* aborted: a newer solve is in flight */ }
}
function sendMessage(md, name = '', email = '') {
unsent = { md, name, email, retried: false }
bridge.send({ token, msg: md, ...(name && { name, email }) })
}
function onPow(msg) {
if (typeof msg.quota === 'number') {
// Proof accepted, quota granted: queued uploads and the racing send
// (rejected for a stale/empty quota) continue.
const waiters = quotaWaiters
quotaWaiters = []
for (const resolve of waiters) resolve(true)
if (unsent && !unsent.retried) {
const { md, name, email } = unsent
unsent.retried = true
bridge.send({ token, msg: md, ...(name && { name, email }) })
}
return
}
}
async function sendMessage(md, name = '', email = '') {
unsent = { md, name, email }
const pow = await currentSolution()
if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) })
}
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, name, email } = unsent
unsent = null
const pow = await currentSolution()
if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) })
}
solverPromise.then(({ solve }) => {
powSolution = solve(msg.challenge, ac.signal)
powSolution.catch(() => {}) // aborts are expected
submitProof()
})
}
function onEmail(msg) {
state.email = msg.email || ''
}
// An upload hit the end of the PoW quota: re-register (the server pushes
// a fresh challenge, solved and proven in the background) and resolve
// once the new quota lands.
function requestQuota() {
return new Promise((resolve) => {
quotaWaiters.push(resolve)
bridge.send({ token }) // re-registration: a fresh challenge follows
})
}
// Unread markers: the has-unread class (a periodic nudge animation, see
@@ -147,6 +168,7 @@ export function refreshBadges() {
function onThread(thread) {
state.thread = thread
unsent = null // the send landed (a thread push acknowledges it)
const lastAdmin = [...thread.messages].reverse().find((m) => m.admin)
if (lastAdmin) localStorage.setItem(LASTADMIN_KEY, lastAdmin.t)
const viewing = state.open && state.mode === 'visitor'
@@ -163,6 +185,7 @@ export function open(br, { admin = false } = {}) {
app = createApp(ContactOverlay, {
state,
token,
reauthorize: requestQuota,
onSend: ({ md, name, email }) => sendMessage(md, name, email),
onClose: () => {
state.open = false
@@ -177,7 +200,7 @@ export function open(br, { admin = false } = {}) {
// state back, and pagerite.js re-registers on every socket reconnect.
// Registered on first open in either mode — the visitor thread is
// also the admin's own (the trigger buttons open it for them).
bridge.register(token, onThread, onPow)
bridge.register(token, onThread, onPow, onEmail)
}
state.mode = admin ? 'admin' : 'visitor'
readContactStrings()
+4 -1
View File
@@ -680,6 +680,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
}
if (msg?.type === "thread") contactListener(msg);
else if (msg?.type === "pow") contactPowListener?.(msg);
else if (msg?.type === "contact-email") contactEmailListener?.(msg);
else if (msg?.type === "contact-error") console.warn("contact:", msg.detail);
};
ws.onclose = () => {
@@ -945,11 +946,13 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
let contactToken = "";
let contactListener = null;
let contactPowListener = null;
let contactEmailListener = null;
const contactBridge = {
register(token, onThread, onPow) {
register(token, onThread, onPow, onEmail) {
contactToken = token;
contactListener = onThread;
contactPowListener = onPow;
contactEmailListener = onEmail;
contactBridge.send({ token });
},
send(msg) {