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:
1 parent
106712a359
commit
7fd73da933
4 files changed
+175
-35
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user