docs: add block/mute user relations, emoji-sync CLI, and uid indexes
DevPlace CI / test (push) Failing after 2m7s
DevPlace CI / test (push) Failing after 2m7s
- Add `/block`, `/mute` endpoints with block/unblock and mute/unmute functionality in `routers/relations.py`, hiding blocked users' content everywhere except their own profile while muting only suppresses notifications - Introduce `devplace emoji-sync` CLI command to regenerate `static/js/emoji-shortcodes.js` from the emoji library, documented in `CLAUDE.md` and wired in `cli.py` - Create `get_blocked_uids()` database helper and apply it in `content.py` `load_detail()` to filter blocked users' posts from detail views - Implement `_uid_index()` and `_drop_index()` helpers in `database.py` for unique uid indexes across tables, with `user_relations` added to `SOFT_DELETE_TABLES` - Document new routes in `AGENTS.md` and `README.md`, including emoji shortcodes rendering behavior distinct from the emoji picker
This commit is contained in:
@@ -259,3 +259,119 @@ def test_interleave_by_author_single_author_keeps_order():
|
||||
rows = [{"uid": i, "user_uid": "a"} for i in range(4)]
|
||||
spread = interleave_by_author(rows)
|
||||
assert [r["uid"] for r in spread] == [0, 1, 2, 3]
|
||||
|
||||
|
||||
def _relation(actor_uid, target_uid, kind):
|
||||
get_table("user_relations").insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": actor_uid,
|
||||
"target_uid": target_uid,
|
||||
"kind": kind,
|
||||
"created_at": _now_db_helpers(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_get_user_relations_partitions_both_kinds(local_db):
|
||||
from devplacepy.database import get_user_relations, invalidate_user_relations
|
||||
|
||||
actor = _user_db_helpers()
|
||||
blocked = _user_db_helpers()
|
||||
muted = _user_db_helpers()
|
||||
_relation(actor, blocked, "block")
|
||||
_relation(actor, muted, "mute")
|
||||
invalidate_user_relations(actor)
|
||||
|
||||
relations = get_user_relations(actor)
|
||||
assert relations["block"] == frozenset({blocked})
|
||||
assert relations["mute"] == frozenset({muted})
|
||||
|
||||
|
||||
def test_get_user_relations_anonymous_is_empty(local_db):
|
||||
from devplacepy.database import get_user_relations
|
||||
|
||||
relations = get_user_relations(None)
|
||||
assert relations["block"] == frozenset()
|
||||
assert relations["mute"] == frozenset()
|
||||
|
||||
|
||||
def test_get_silenced_uids_is_union(local_db):
|
||||
from devplacepy.database import get_silenced_uids, invalidate_user_relations
|
||||
|
||||
actor = _user_db_helpers()
|
||||
blocked = _user_db_helpers()
|
||||
muted = _user_db_helpers()
|
||||
_relation(actor, blocked, "block")
|
||||
_relation(actor, muted, "mute")
|
||||
invalidate_user_relations(actor)
|
||||
|
||||
assert get_silenced_uids(actor) == frozenset({blocked, muted})
|
||||
|
||||
|
||||
def test_soft_deleted_relation_is_ignored(local_db):
|
||||
from devplacepy.database import get_blocked_uids, invalidate_user_relations
|
||||
|
||||
actor = _user_db_helpers()
|
||||
target = _user_db_helpers()
|
||||
_relation(actor, target, "block")
|
||||
invalidate_user_relations(actor)
|
||||
assert get_blocked_uids(actor) == frozenset({target})
|
||||
|
||||
row = get_table("user_relations").find_one(user_uid=actor, target_uid=target)
|
||||
get_table("user_relations").update(
|
||||
{"id": row["id"], "deleted_at": _now_db_helpers()}, ["id"]
|
||||
)
|
||||
invalidate_user_relations(actor)
|
||||
assert get_blocked_uids(actor) == frozenset()
|
||||
|
||||
|
||||
def test_paginate_excludes_blocked_authors(local_db):
|
||||
from devplacepy.database import paginate, invalidate_user_relations
|
||||
|
||||
viewer = _user_db_helpers()
|
||||
blocked = _user_db_helpers()
|
||||
visible = _user_db_helpers()
|
||||
blocked_post = _post(blocked)
|
||||
visible_post = _post(visible)
|
||||
_relation(viewer, blocked, "block")
|
||||
invalidate_user_relations(viewer)
|
||||
|
||||
rows, _ = paginate(get_table("posts"), viewer_uid=viewer)
|
||||
uids = {r["uid"] for r in rows}
|
||||
assert visible_post in uids
|
||||
assert blocked_post not in uids
|
||||
|
||||
|
||||
def test_paginate_without_viewer_keeps_all(local_db):
|
||||
from devplacepy.database import paginate, invalidate_user_relations
|
||||
|
||||
viewer = _user_db_helpers()
|
||||
blocked = _user_db_helpers()
|
||||
blocked_post = _post(blocked)
|
||||
_relation(viewer, blocked, "block")
|
||||
invalidate_user_relations(viewer)
|
||||
|
||||
rows, _ = paginate(get_table("posts"), viewer_uid=None)
|
||||
assert blocked_post in {r["uid"] for r in rows}
|
||||
|
||||
|
||||
def test_load_comments_drops_blocked_author(local_db):
|
||||
from devplacepy.database import load_comments, invalidate_user_relations
|
||||
|
||||
viewer = _user_db_helpers()
|
||||
host = _user_db_helpers()
|
||||
blocked = _user_db_helpers()
|
||||
post_uid = _post(host)
|
||||
_comment(blocked, post_uid)
|
||||
_relation(viewer, blocked, "block")
|
||||
invalidate_user_relations(viewer)
|
||||
|
||||
viewer_row = get_table("users").find_one(uid=viewer)
|
||||
comments = load_comments("post", post_uid, viewer_row)
|
||||
assert comments == []
|
||||
|
||||
others = load_comments("post", post_uid, None)
|
||||
assert len(others) == 1
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from devplacepy.rendering import render_content, render_title
|
||||
|
||||
|
||||
@@ -110,3 +112,154 @@ def test_title_escapes_raw_html():
|
||||
def test_caching_is_idempotent():
|
||||
text = "cached **content** :fire:"
|
||||
assert str(render_content(text)) == str(render_content(text))
|
||||
|
||||
|
||||
DANGEROUS_SCHEMES = {"javascript", "data", "vbscript", "file"}
|
||||
|
||||
|
||||
class _XSSAudit(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.violations: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
self._inspect(tag, attrs)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list) -> None:
|
||||
self._inspect(tag, attrs)
|
||||
|
||||
def _inspect(self, tag: str, attrs: list) -> None:
|
||||
if tag in {"script", "object", "embed"}:
|
||||
self.violations.append(f"<{tag}>")
|
||||
for name, value in attrs:
|
||||
lname = name.lower()
|
||||
if lname.startswith("on"):
|
||||
self.violations.append(f"{tag}@{name}")
|
||||
if lname in {"href", "src", "xlink:href", "formaction"} and value:
|
||||
scheme = value.split(":", 1)[0].strip().lower() if ":" in value else ""
|
||||
if scheme in DANGEROUS_SCHEMES:
|
||||
self.violations.append(f"{tag}@{name}={value}")
|
||||
|
||||
|
||||
def assert_no_executable_html(rendered: str) -> None:
|
||||
audit = _XSSAudit()
|
||||
audit.feed(rendered)
|
||||
audit.close()
|
||||
assert not audit.violations, f"XSS vectors survived rendering: {audit.violations}"
|
||||
|
||||
|
||||
XSS_VECTORS = (
|
||||
"<script>alert('xss')</script>",
|
||||
"<img src=x onerror=alert('xss')>",
|
||||
"<svg onload=alert('xss')>",
|
||||
"<svg><script>alert('xss')</script></svg>",
|
||||
"<body onload=alert('xss')>",
|
||||
"<iframe src=\"javascript:alert('xss')\"></iframe>",
|
||||
"<a href=\"javascript:alert('xss')\">click</a>",
|
||||
"<div onmouseover=\"alert('xss')\">hover</div>",
|
||||
"<input autofocus onfocus=alert('xss')>",
|
||||
"<object data=\"javascript:alert('xss')\"></object>",
|
||||
"<a href=\"data:text/html,<script>alert('xss')</script>\">x</a>",
|
||||
"[click](javascript:alert('xss'))",
|
||||
")",
|
||||
"[click](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
|
||||
"[click](vbscript:msgbox('xss'))",
|
||||
"normal text <ScRiPt>alert('xss')</ScRiPt> mixed case",
|
||||
"tab\tin\tjava\tscript:alert('xss')",
|
||||
"<img src=`x`onerror=alert('xss')>",
|
||||
"<a href=\" javascript:alert('xss')\">leading space</a>",
|
||||
)
|
||||
|
||||
|
||||
def _audit_content_vectors() -> None:
|
||||
for vector in XSS_VECTORS:
|
||||
assert_no_executable_html(str(render_content(vector)))
|
||||
assert_no_executable_html(str(render_content(f"hello {vector} world")))
|
||||
|
||||
|
||||
def _audit_title_vectors() -> None:
|
||||
for vector in XSS_VECTORS:
|
||||
assert_no_executable_html(str(render_title(vector)))
|
||||
assert_no_executable_html(str(render_title(f"title {vector} end")))
|
||||
|
||||
|
||||
def test_xss_post_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_post_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_comment_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_project_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_project_description_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_gist_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_gist_description_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_news_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_news_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_message_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_poll_question_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_poll_option_label_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_saved_item_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_leaderboard_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_issue_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_issue_body_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_legitimate_youtube_embed_survives_audit():
|
||||
out = str(render_content("watch https://youtu.be/dQw4w9WgXcQ now"))
|
||||
assert "youtube.com/embed/dQw4w9WgXcQ" in out
|
||||
assert_no_executable_html(out)
|
||||
|
||||
|
||||
def test_xss_legitimate_image_embed_survives_audit():
|
||||
out = str(render_content("pic https://example.com/a.png end"))
|
||||
assert "<img" in out
|
||||
assert_no_executable_html(out)
|
||||
|
||||
|
||||
def test_xss_legitimate_link_survives_audit():
|
||||
out = str(render_content("see https://example.com/page ok"))
|
||||
assert 'href="https://example.com/page"' in out
|
||||
assert_no_executable_html(out)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
from devplacepy.services.bot import bot as bot_module
|
||||
from devplacepy.services.bot.bot import DevPlaceBot
|
||||
|
||||
STALE_KEY = "019ec5b5-410c-7660-a129-c80589859080"
|
||||
OWN_KEY = "019ed655-b699-7fa0-bf25-dd77910fb6d4"
|
||||
|
||||
|
||||
def _fake_bot(cached_key, fetched_key):
|
||||
fake = types.SimpleNamespace()
|
||||
fake.state = types.SimpleNamespace(account_api_key=cached_key)
|
||||
fake.llm = types.SimpleNamespace(api_key=cached_key)
|
||||
fake.saves = []
|
||||
fake.logs = []
|
||||
|
||||
async def _fetch():
|
||||
return fetched_key
|
||||
|
||||
fake._fetch_account_api_key = _fetch
|
||||
fake._save = lambda: fake.saves.append(fake.state.account_api_key)
|
||||
fake._log = lambda *args, **kwargs: fake.logs.append(args[0] if args else "")
|
||||
return fake
|
||||
|
||||
|
||||
def test_adopt_replaces_stale_cached_key_from_recycled_slot():
|
||||
fake = _fake_bot(cached_key=STALE_KEY, fetched_key=OWN_KEY)
|
||||
result = asyncio.run(DevPlaceBot._adopt_account_api_key(fake))
|
||||
assert result is True
|
||||
assert fake.llm.api_key == OWN_KEY
|
||||
assert fake.state.account_api_key == OWN_KEY
|
||||
assert fake.saves == [OWN_KEY]
|
||||
|
||||
|
||||
def test_adopt_keeps_matching_key_without_resaving():
|
||||
fake = _fake_bot(cached_key=OWN_KEY, fetched_key=OWN_KEY)
|
||||
result = asyncio.run(DevPlaceBot._adopt_account_api_key(fake))
|
||||
assert result is True
|
||||
assert fake.llm.api_key == OWN_KEY
|
||||
assert fake.saves == []
|
||||
|
||||
|
||||
def test_adopt_fails_when_own_key_unavailable(monkeypatch):
|
||||
async def _instant_sleep(_seconds):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(bot_module.asyncio, "sleep", _instant_sleep)
|
||||
fake = _fake_bot(cached_key=STALE_KEY, fetched_key="")
|
||||
result = asyncio.run(DevPlaceBot._adopt_account_api_key(fake))
|
||||
assert result is False
|
||||
assert fake.llm.api_key == STALE_KEY
|
||||
assert fake.saves == []
|
||||
@@ -46,3 +46,34 @@ def test_seo_report_is_public_read_only_http_action():
|
||||
assert "seo_report" not in CONFIRM_REQUIRED
|
||||
names = {p.name for p in action.params}
|
||||
assert "uid" in names
|
||||
|
||||
|
||||
def test_block_mute_tools_exist_as_http_actions():
|
||||
expected = {
|
||||
"block_user": "/block/{username}",
|
||||
"unblock_user": "/block/unblock/{username}",
|
||||
"mute_user": "/mute/{username}",
|
||||
"unmute_user": "/mute/unmute/{username}",
|
||||
}
|
||||
for name, path in expected.items():
|
||||
action = BY_NAME[name]
|
||||
assert action.handler == "http"
|
||||
assert action.method == "POST"
|
||||
assert action.path == path
|
||||
assert action.requires_auth is True
|
||||
assert action.requires_admin is False
|
||||
assert "username" in {p.name for p in action.params}
|
||||
|
||||
|
||||
def test_block_mute_tools_visible_to_authenticated_user():
|
||||
schemas = PLATFORM_CATALOG.tool_schemas_for(authenticated=True, is_admin=False)
|
||||
names = {s["function"]["name"] for s in schemas}
|
||||
for name in ("block_user", "unblock_user", "mute_user", "unmute_user"):
|
||||
assert name in names
|
||||
|
||||
|
||||
def test_block_mute_tools_hidden_from_guests():
|
||||
schemas = PLATFORM_CATALOG.tool_schemas_for(authenticated=False)
|
||||
names = {s["function"]["name"] for s in schemas}
|
||||
for name in ("block_user", "unblock_user", "mute_user", "unmute_user"):
|
||||
assert name not in names
|
||||
|
||||
+9
-1
@@ -1,6 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table, get_primary_admin_uid
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_primary_admin_uid,
|
||||
invalidate_admins_cache,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
award_badge,
|
||||
award_xp,
|
||||
@@ -168,6 +172,7 @@ def _seed_user_at(role, created_at):
|
||||
"created_at": created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
invalidate_admins_cache()
|
||||
return get_table("users").find_one(uid=uid)
|
||||
|
||||
|
||||
@@ -175,12 +180,14 @@ def _demote_existing_admins():
|
||||
existing = [r["uid"] for r in get_table("users").find(role="Admin")]
|
||||
for uid in existing:
|
||||
get_table("users").update({"uid": uid, "role": "Member"}, ["uid"])
|
||||
invalidate_admins_cache()
|
||||
return existing
|
||||
|
||||
|
||||
def _restore_admins(uids):
|
||||
for uid in uids:
|
||||
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
|
||||
invalidate_admins_cache()
|
||||
|
||||
|
||||
def _purge(*rows):
|
||||
@@ -219,6 +226,7 @@ def test_primary_admin_reassigns_when_founder_demoted(local_db):
|
||||
get_table("users").update(
|
||||
{"uid": admin_first["uid"], "role": "Member"}, ["uid"]
|
||||
)
|
||||
invalidate_admins_cache()
|
||||
assert get_primary_admin_uid() == admin_second["uid"]
|
||||
assert (
|
||||
is_primary_admin(get_table("users").find_one(uid=admin_second["uid"]))
|
||||
|
||||
Reference in New Issue
Block a user