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:
1 parent
7fd73da933
commit
2843469e04
7 files changed
+105
-40
No files matched your search
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user