contact messages: PoW quota, post-solve email reveal, analytics trail marker

The client now submits each solved challenge as soon as it is ready
({token, pow}); a validated proof grants a quota of 10 messages/uploads
(RAM, keyed by token hash so the HTTP upload route shares it) and a
fresh challenge is pushed only when the quota runs out. The site
contact email leaves the page asset config and is revealed over the
socket only after a solve. A validated proof also records a
server-originated contact marker in the analytics raw log, which
display() lands on the visit's trail step where it first happened.
This commit is contained in:
LeoVasanko committed 2026-09-28 14:33:17 +00:00
1 parent 106712a359
commit 7fd73da933
4 files changed
+175 -35

No files matched your search

+51
View File
@@ -148,6 +148,10 @@ class Msg(msgspec.Struct, omit_defaults=True):
#: Rendered language reported by the client for the page the activity
#: happened on.
lang: str = ""
#: Server-originated contact marker (docs/messages.md): the visitor
#: opened the contact panel and solved its proof of work on ``fr``.
#: Never sent by clients — recorded by the /_ws handler.
contact: bool = False
class Client(msgspec.Struct, omit_defaults=True):
@@ -217,6 +221,9 @@ class TrailItem(msgspec.Struct, omit_defaults=True):
#: page falling back to its GET's rendered language; "" when unknown
#: (old clients or data from before language recording).
lang: str = ""
#: The visitor opened the contact panel and solved its proof of work
#: on this page (docs/messages.md) — the trail's ✉️ marker.
contact: bool = False
class Visit(msgspec.Struct, omit_defaults=True):
@@ -711,6 +718,34 @@ class Store:
self._save()
return client_hash if new else None
def record_contact(
self, fr: str, ip: str, ua: str, accept_language: str = ""
) -> bytes | None:
"""Append one server-originated contact marker to the raw log: the
visitor opened the contact panel on ``fr`` and solved its proof of
work (docs/messages.md). Recorded by the /_ws handler on a
validated solution — an unsolved panel open leaves no trace.
Display lands the marker on the visit's trail step for ``fr``.
Returns the client hash when the client record was just created (so
the caller can schedule async enrichment), else None.
"""
client_lang, country = _parse_accept_language(accept_language)
client_hash = _client_hash(ip, ua, client_lang)
new = client_hash not in self.data.clients
if new:
self._ensure_client(ip, ua, client_lang, country=country)
self.data.msgs.append(
Msg(
t=datetime.now(UTC),
client=client_hash,
fr=(_internal_path(fr) or "") if fr else "",
contact=True,
)
)
self._save()
return client_hash if new else None
def favicon_origins_needed(self) -> list[str]:
"""External https origins seen in the raw events whose favicon needs
fetching.
@@ -775,6 +810,9 @@ class Store:
rendered language), and a page re-visited in a different rendered
language becomes a distinct trail step. The entry referer and
UTM tags come from the GET that loaded the entry page.
Server-originated contact markers (the contact panel's proof of
work solved, docs/messages.md) flag the trail step they happened
on — the first one per visit.
Hidden (admin) clients are excluded from every list and aggregate.
``multilingual`` and ``primary_lang`` are site context (translation
@@ -871,6 +909,19 @@ class Store:
visit: Visit | None = None
last_t: datetime | None = None
for m in msgs:
if m.contact:
# Contact PoW proven (docs/messages.md): mark the trail
# step it happened on — the first one per visit only.
if visit is not None and not any(
item.contact for item in visit.trail.values()
):
want = m.fr or visit.entry
for item in visit.trail.values():
if item.to == want:
item.contact = True
break
last_t = m.t
continue
if m.to:
if visit is None or (
last_t is not None and m.t - last_t > _SESSION_GAP
+73 -19
View File
@@ -13,15 +13,18 @@ pushed full thread payloads (threads are small); there is no REST
polling anywhere.
Sends are proof-of-work gated (easypow): the /_ws handler
(tracking.activity_ws) issues a fresh single-use challenge with every
registration/resync and after every accepted message, and validates and
burns the solution before forwarding a send here — challenges are a
per-connection local there. The browser solves in the background with
the bundled solver (served at /_contact/easypow.js). Uploads are not
PoW-gated — the transfer rate is the real cost there — but uploaded
files stay pending per session and are deleted when its last socket
closes without a sent message referencing them; a quota caps pending
files.
(tracking.activity_ws) issues a fresh single-use challenge on
registration/resync and whenever the quota runs out; the browser solves
it in the background (bundled solver, served at /_contact/easypow.js)
and submits the solution as soon as it is ready (``visitor_proven``),
which grants a quota of messages and uploads (``_quota``, RAM only,
keyed by token hash so the HTTP upload route shares it). The solution
is single-use — a fresh challenge is pushed when the quota hits zero,
which the client solves before continuing. A validated proof also
unlocks the site's contact email (sent over the socket, never in the
page's asset config) and records the visit's contact trail marker in
analytics. Uploads without quota are answered 429; the client re-proves
and retries.
Message bodies are a Markdown subset (emphasis, links, lists, code plus
/_f/ image and file references) serialized client-side from a constrained
@@ -71,8 +74,17 @@ _admin_ws: set[WebSocket] = set()
#: per-connection local in the WS handler (tracking.activity_ws).
_pending_uploads: dict[bytes, set[str]] = {}
#: easypow solving cost per message and the pending-upload quota.
#: Remaining message/upload actions per session (token hash), granted by a
#: solved PoW challenge (RAM only — single worker; the challenge itself is
#: a per-connection local in tracking.activity_ws). Keyed by token hash
#: rather than connection so the HTTP upload route (/_contact/files)
#: shares the same budget, and a reconnect keeps it.
_quota: dict[bytes, int] = {}
#: easypow solving cost per challenge, the actions a solution grants
#: (messages and uploads alike), and the pending-upload quota.
POW_SECONDS = 5.0
POW_QUOTA = 10
MAX_PENDING_UPLOADS = 10
@@ -222,12 +234,47 @@ def _append(
# --- Visitor protocol (/_ws frames carrying "token") ------------------------
def quota_left(token: str) -> int:
"""Remaining PoW-granted actions for a session token (0 for malformed
tokens — they fail validation later anyway)."""
if not TOKEN_RE.fullmatch(token):
return 0
return _quota.get(thread_key(token), 0)
async def visitor_proven(ws: WebSocket, raw: dict) -> bytes | None:
"""A solved PoW challenge submitted ahead of sending ({token, pow} —
the solution itself is already validated and burned by the WS
handler): grant the message/upload quota, acknowledge it, reveal the
site's contact email (never carried in the page's asset config, so
scrapers cannot lift it without solving) and return the thread key
(the handler records the visit's contact trail marker in analytics).
None for a malformed token."""
token = str(raw.get("token") or "")
if not TOKEN_RE.fullmatch(token):
await ws.send_json({"type": "contact-error", "detail": "bad session token"})
return None
key = thread_key(token)
_visitor_ws.setdefault(key, set()).add(ws)
_quota[key] = POW_QUOTA
await ws.send_json({"type": "pow", "quota": POW_QUOTA})
if data.contact_email:
await ws.send_json({"type": "contact-email", "email": data.contact_email})
return key
def _spend(key: bytes) -> None:
"""Burn one message/upload of the session's PoW quota."""
if _quota.get(key, 0) > 0:
_quota[key] -= 1
async def visitor_frame(ws: WebSocket, raw: dict) -> None:
"""Handle one visitor frame: ``{token}`` registers/resyncs (the thread
state is pushed back), ``{token, msg, fr?}`` appends a message and
broadcasts the updated thread to both sides. PoW extraction and
validation happen in the WS handler (tracking.activity_ws) — message
frames arrive here already vetted."""
broadcasts the updated thread to both sides. The PoW quota check
happens in the WS handler (tracking.activity_ws) — message frames
arrive here with quota remaining, spent here per accepted message."""
token = str(raw.get("token") or "")
if not TOKEN_RE.fullmatch(token):
await ws.send_json({"type": "contact-error", "detail": "bad session token"})
@@ -271,6 +318,7 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None:
name,
email,
)
_spend(key)
# Files the message references are no longer pending cleanup.
if pending := _pending_uploads.get(key):
for digest in re.findall(r"/_f/([0-9a-f]{12})", text):
@@ -392,11 +440,13 @@ async def contact_upload(name: str, request: Request) -> dict:
"""Store a contact-panel upload in the content-addressed store.
Public like the messaging socket (only /_api is gated): the caller
must present a session-shaped token and size is capped. Uploads are
deliberately not PoW-gated (the transfer rate is the real cost);
instead the file stays pending per session and is deleted when the
session's last socket closes without a sent message referencing it,
and a quota (429, not 403 — no auth flows here) caps pending files.
must present a session-shaped token and size is capped. Uploads spend
the same PoW quota as messages (a solved challenge covers both): with
no quota left the answer is 429 "pow" (429, not 403 — no auth flows
here) and the client re-proves and retries. The file additionally
stays pending per session and is deleted when the session's last
socket closes without a sent message referencing it, and a separate
quota caps those pending files.
Images (raster and SVG) get the usual derivative treatment and are
referenced extension-less; any other file is stored as an inert
{hash}.file blob and referenced as /_f/{hash}/{original-name}, which
@@ -406,7 +456,10 @@ async def contact_upload(name: str, request: Request) -> dict:
token = request.headers.get("x-contact-token", "")
if not TOKEN_RE.fullmatch(token):
raise HTTPException(403, "no session")
pending = _pending_uploads.setdefault(thread_key(token), set())
key = thread_key(token)
if _quota.get(key, 0) <= 0:
raise HTTPException(429, "pow")
pending = _pending_uploads.setdefault(key, set())
if len(pending) >= MAX_PENDING_UPLOADS:
raise HTTPException(429, "too many un-sent uploads — send your message first")
try:
@@ -420,6 +473,7 @@ async def contact_upload(name: str, request: Request) -> dict:
raise HTTPException(400, "empty file")
if len(body) > UPLOAD_MAX_BYTES:
raise HTTPException(413, "file too large")
_spend(key)
orig = Path(name).name[:100] or "file"
ext = _ext(orig)
if ext in IMAGE_EXTS:
+44 -9
View File
@@ -452,10 +452,14 @@ async def activity_ws(ws: WebSocket) -> None:
Frames carrying a ``token`` field are visitor contact messages
(docs/messages.md), routed to pagerite.messages — the same socket
serves the contact panel, which also receives thread pushes on it.
Message sends are proof-of-work gated at this level: a single-use
easypow challenge (a per-connection local) is issued on registration
and after every vetted message frame, and validated/burned before a
message frame is forwarded.
Sends and uploads spend a per-session quota granted by solving an
easypow challenge: a single-use challenge (a per-connection local) is
issued on registration and whenever the quota runs out; the client
submits the solution as soon as it is ready ({token, pow}), validated
and burned here, and the quota itself lives in pagerite.messages
keyed by token hash (shared with the HTTP upload route, kept across
reconnects). A validated proof also reveals the site's contact email
and records the visit's contact trail marker in analytics.
"""
ip = _client_ip(ws)
ua = ws.headers.get("user-agent", "")
@@ -476,12 +480,29 @@ async def activity_ws(ws: WebSocket) -> None:
except msgspec.DecodeError:
continue
if isinstance(raw, dict) and "token" in raw:
# Contact-messaging frames (docs/messages.md). Sends are
# gated by a single-use easypow challenge held in the
# connection local above: validated and burned here, a
# fresh one issued after every registration and every
# vetted message frame.
# Contact-messaging frames (docs/messages.md).
if raw.get("msg"):
# A send with no quota left gets a fresh challenge
# instead; the client solves it, re-proves and retries.
if messages.quota_left(str(raw.get("token") or "")) <= 0:
challenge = easypow.generate(messages.POW_SECONDS)
await ws.send_json(
{"type": "pow", "challenge": challenge, "required": True}
)
continue
await messages.visitor_frame(ws, raw)
if messages.quota_left(str(raw.get("token") or "")) <= 0:
# Quota just ran out: push the next challenge right
# away so the client can pre-solve it.
challenge = easypow.generate(messages.POW_SECONDS)
await ws.send_json(
{"type": "pow", "challenge": challenge, "required": False}
)
continue
if "pow" in raw:
# A solved challenge, submitted as soon as ready:
# validate and burn it here, grant the quota in
# pagerite.messages (shared with the upload route).
try:
if not challenge:
raise ValueError("no outstanding challenge")
@@ -492,6 +513,20 @@ async def activity_ws(ws: WebSocket) -> None:
{"type": "pow", "challenge": challenge, "required": True}
)
continue
challenge = "" # burned; a fresh one comes when the quota ends
key = await messages.visitor_proven(ws, raw)
if key is not None:
# Panel opened AND PoW solved: mark the visit's
# trail at the page it first happened on.
new_client = analytics_store.record_contact(
str(raw.get("fr") or ""), ip, ua, accept_language
)
if new_client is not None:
_schedule_client_enrichment([new_client])
_schedule_analytics_broadcast()
continue
# Registration/resync: the thread state is pushed back, and
# a fresh challenge is issued for the client to pre-solve.
await messages.visitor_frame(ws, raw)
challenge = easypow.generate(messages.POW_SECONDS)
await ws.send_json(
+7 -7
View File
@@ -433,14 +433,13 @@ def _layout(
if editor_css:
config["pagerite:editor-css"] = editor_css
# Localized contact-messaging strings (label = the {contact:} buttons'
# default and the visitor dialog's title, mailto address + subject).
# default and the visitor dialog's title, mailto subject — the address
# itself is revealed over the socket only after the PoW is solved).
# Absent entirely when messaging is disabled: pagerite.js then shows
# the corner ✉️ icon to admins only.
if contact is not None:
config["pagerite:contact-label"] = contact["label"]
config["pagerite:contact-subject"] = contact["subject"]
if contact["email"]:
config["pagerite:contact-email"] = contact["email"]
if vite_url:
for key, value in config.items():
doc.meta(name=key, content=value)
@@ -1009,13 +1008,14 @@ _CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.MULTILINE)
def _contact_config(data: Data, lang: str) -> dict[str, str]:
"""The localized contact-messaging strings for the page's asset config
(pagerite:contact-*): the {contact:} buttons' default label doubling as
the visitor dialog's title, the mailto address and its subject (the
label when no separate subject is configured). Translations come from
the chunk-keyed trans store (i18n.site_string)."""
the visitor dialog's title, and the mailto subject (the label when no
separate subject is configured). Translations come from the
chunk-keyed trans store (i18n.site_string). The email address itself
is NOT here — it is revealed over the messaging socket only after the
proof of work is solved, so scrapers cannot lift it from pages."""
label = i18n.site_string(data, lang, data.contact_label or "Contact us")
return {
"label": label,
"email": data.contact_email,
"subject": i18n.site_string(data, lang, data.contact_subject) or label,
}