krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users.

clone: git clone https://gitbay.org/krz/hutch-stats.git

daaeba8be8df4d0876312e540bdd420e16ff724c

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T04:02:32Z

validate scraped usernames before enqueueing actors
 src/srht_contrib/scripts/enqueue_actors.py | 27 ++++++++++++++++++++-------
 tests/test_ingestion.py                    | 24 ++++++++++++++++++++++++
 2 files changed, 44 insertions(+), 7 deletions(-)

diff --git a/src/srht_contrib/scripts/enqueue_actors.py b/src/srht_contrib/scripts/enqueue_actors.py
index f6999af..03f1f9b 100644
--- a/src/srht_contrib/scripts/enqueue_actors.py
+++ b/src/srht_contrib/scripts/enqueue_actors.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import argparse
 from datetime import UTC, datetime, timedelta
 from pathlib import Path
+import re
 
 from sqlalchemy import select
 
@@ -11,19 +12,31 @@ from srht_contrib.db import make_session_factory
 from srht_contrib.models import TrackedActor
 
 
+USERNAME_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$")
+
+
+def _normalize_actor(raw_username: str) -> str | None:
+    username = raw_username.strip()
+    if not username or username.startswith("#"):
+        return None
+    if username.startswith("~"):
+        username = username[1:]
+    if not USERNAME_RE.fullmatch(username):
+        return None
+    return f"~{username}"
+
+
 def _iter_usernames(path: Path) -> list[str]:
     usernames: list[str] = []
     seen: set[str] = set()
     for raw_line in path.read_text(encoding="utf-8").splitlines():
-        username = raw_line.strip()
-        if not username or username.startswith("#"):
+        actor = _normalize_actor(raw_line)
+        if actor is None:
             continue
-        if not username.startswith("~"):
-            username = f"~{username}"
-        if username in seen:
+        if actor in seen:
             continue
-        seen.add(username)
-        usernames.append(username)
+        seen.add(actor)
+        usernames.append(actor)
     return usernames
 
 
diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py
index 974dac6..6a1a162 100644
--- a/tests/test_ingestion.py
+++ b/tests/test_ingestion.py
@@ -738,3 +738,27 @@ def test_enqueue_actors_staggers_without_polling(tmp_path, monkeypatch) -> None:
     assert actors[0].next_poll_after == queued_at.replace(tzinfo=None)
     assert actors[1].next_poll_after == (queued_at + timedelta(seconds=60)).replace(tzinfo=None)
     assert actors[2].next_poll_after == (queued_at + timedelta(seconds=120)).replace(tzinfo=None)
+
+
+def test_enqueue_actors_skips_invalid_usernames(tmp_path, monkeypatch) -> None:
+    database_path = tmp_path / "enqueue-invalid.db"
+    username_path = tmp_path / "srht_usernames.txt"
+    username_path.write_text("-0\n.\n~bad-\nvalid_user\nok.ok\n", encoding="utf-8")
+    monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_path}")
+    monkeypatch.setenv("SRHT_TOKEN", "test-token")
+    monkeypatch.setenv("DEFAULT_ACTOR", "~ccleberg")
+
+    from srht_contrib.db import Base, make_engine, make_session_factory
+
+    settings = Settings()
+    engine = make_engine(settings)
+    Base.metadata.create_all(bind=engine)
+    session_factory = make_session_factory(settings)
+
+    inserted = enqueue_actors(Path(username_path), stagger_seconds=60)
+
+    with session_factory() as db:
+        actors = db.scalars(select(TrackedActor).order_by(TrackedActor.actor)).all()
+
+    assert inserted == 2
+    assert [actor.actor for actor in actors] == ["~ok.ok", "~valid_user"]