diff --git a/pagerite/analytics.py b/pagerite/analytics.py index 45ad4f5..f841aed 100644 --- a/pagerite/analytics.py +++ b/pagerite/analytics.py @@ -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 diff --git a/pagerite/messages.py b/pagerite/messages.py index 2e9f0c5..1ec3188 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -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: diff --git a/pagerite/tracking.py b/pagerite/tracking.py index 2df0b66..f2a7bea 100644 --- a/pagerite/tracking.py +++ b/pagerite/tracking.py @@ -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( diff --git a/pagerite/views.py b/pagerite/views.py index 1b05843..b70fff7 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -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, }