From 43c5a948e8dbc534b8bacdb078125fa07309bd6f Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 19 Jul 2026 21:26:18 +0200 Subject: [PATCH] Privacy --- CLAUDE.md | 2 ++ devplacepy/rendering.py | 25 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 328958fa..417e531a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,6 +193,8 @@ The CLIENT pipeline (`ContentRenderer.js`, `dp-content`/`dp-title`) is retained **Emoji shortcodes are the full GitHub/Discord `:name:` set** (~4869 names), generated once from the `emoji` library by `rendering.py` `build_emoji_shortcodes()`; the frontend gets the identical map via the generated `static/js/emoji-shortcodes.js` (regenerate with `devplace emoji-sync` after bumping the dependency, never hand-edit). +**Email anonymization:** both `_render_content` and `_render_title` mask email addresses to prevent doxing. `_mask_emails` in `rendering.py` runs on rendered text nodes only (inside `_transform_text`, `_MediaProcessor`, and `_InlineFilter` - never before mistune, or the `*` mask characters would be parsed as emphasis) and stars ~80% of the local part (keeps a leading 20%, minimum one visible char). Addresses on `molodetz.nl` (and its subdomains) are exempt and render verbatim. + **Em-dash normalization:** `_normalize_dashes` in `rendering.py` replaces all forms (em dash `\u2014`, en dash `\u2013`, and their HTML entities) with a hyphen BEFORE mistune processes the text. Runs inside `_render_content`/`_render_title` which are `@lru_cache`d, so each unique text is normalized once. No per-template overhead. ### Auth diff --git a/devplacepy/rendering.py b/devplacepy/rendering.py index 86e05418..30966fe9 100644 --- a/devplacepy/rendering.py +++ b/devplacepy/rendering.py @@ -69,6 +69,9 @@ _YOUTUBE_ALLOW = ( "gyroscope; picture-in-picture" ) +_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b") +_EMAIL_KEEP_DOMAIN = "molodetz.nl" + _MEDIA_SKIP_TAGS = {"a", "code", "pre"} _TITLE_INLINE_TAGS = { "b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br", @@ -153,12 +156,26 @@ def _embed_url(url: str) -> str: ) +def _mask_email(match: re.Match) -> str: + email = match.group(0) + local, _, domain = email.partition("@") + lowered = domain.lower() + if lowered == _EMAIL_KEEP_DOMAIN or lowered.endswith("." + _EMAIL_KEEP_DOMAIN): + return email + reveal = max(1, len(local) - round(len(local) * 0.8)) + return f"{local[:reveal]}{'*' * (len(local) - reveal)}@{domain}" + + +def _mask_emails(text: str) -> str: + return _EMAIL_RE.sub(_mask_email, text) + + def _transform_text(text: str) -> str: out: list[str] = [] pos = 0 for match in _TOKEN_RE.finditer(text): if match.start() > pos: - out.append(html.escape(text[pos:match.start()])) + out.append(html.escape(_mask_emails(text[pos:match.start()]))) if match.group("url"): out.append(_embed_url(match.group("url"))) else: @@ -169,7 +186,7 @@ def _transform_text(text: str) -> str: ) pos = match.end() if pos < len(text): - out.append(html.escape(text[pos:])) + out.append(html.escape(_mask_emails(text[pos:]))) return "".join(out) @@ -205,7 +222,7 @@ class _MediaProcessor(HTMLParser): def handle_data(self, data: str) -> None: if self._skip_depth > 0: - self._out.append(html.escape(data)) + self._out.append(html.escape(_mask_emails(data))) else: self._out.append(_transform_text(data)) @@ -231,7 +248,7 @@ class _InlineFilter(HTMLParser): self._out.append(f"") def handle_data(self, data: str) -> None: - self._out.append(html.escape(data)) + self._out.append(html.escape(_mask_emails(data))) def result(self) -> str: return "".join(self._out).strip()