summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 05:17:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 05:17:28 +0200
commit42556800d103ede20b4e97f2d91d20bbc0000c1e (patch)
tree1a392221995f4e8092bd20fb3acdd95a28c1d1f1
parent1734c668406c66e2be63e0c6999b4b2af2f60808 (diff)
downloadmeshbay-42556800d103ede20b4e97f2d91d20bbc0000c1e.tar.gz
feat(hub): add moderation — content blocklist + reports — 5.9
DB: ContentReport + ContentBlocklist tables. POST /v1/reports: public endpoint, auto-blocks after 2 reports. GET /v1/blocklist/check: node sync check before serving public content. GET /v1/blocklist: full list for node startup sync. GET|POST|DELETE /v1/admin/blocklist: admin management. 6/6 tests. Full suite: 59/59. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py200
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py35
-rw-r--r--packages/meshbay-hub/tests/test_moderation.py95
4 files changed, 333 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
new file mode 100644
index 0000000..a8bf84f
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -0,0 +1,200 @@
+"""
+MeshBay Hub — moderation endpoints.
+
+Public reporting flow:
+ POST /v1/reports — report a content hash (no auth required)
+
+ Thresholds:
+ 1st report → logged, node admin notified (future: push notification)
+ 2nd report → content hash added to blocklist automatically
+ 3rd+ report → logged as repeat offense (escalation for human review)
+
+Admin endpoints:
+ GET /v1/admin/blocklist — list blocked hashes
+ POST /v1/admin/blocklist — manually add a hash
+ DELETE /v1/admin/blocklist/{hash} — remove a hash
+
+Node integration:
+ GET /v1/blocklist/check?hash=<blake3> — check if a hash is blocked
+ GET /v1/blocklist — full blocklist (for node sync)
+"""
+
+import logging
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import ContentBlocklist, ContentReport, User
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(tags=["moderation"])
+
+AUTO_BLOCK_THRESHOLD = 2 # reports before automatic block
+
+
+# ── Models ────────────────────────────────────────────────────────────────────
+
+class ReportRequest(BaseModel):
+ content_hash: str # blake3 hex (64 chars)
+ group_id: str | None = None
+ reason: str = "illegal"
+ detail: str | None = None
+
+
+class BlocklistAddRequest(BaseModel):
+ content_hash: str
+ reason: str
+
+
+# ── Public endpoints ──────────────────────────────────────────────────────────
+
+@router.post("/v1/reports", status_code=201)
+async def report_content(
+ body: ReportRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """Report a content hash. No authentication required."""
+ if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash):
+ raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
+
+ ip = _ip(request)
+
+ # Count existing reports for this hash
+ count_result = await db.execute(
+ select(func.count()).where(ContentReport.content_hash == body.content_hash))
+ count = count_result.scalar_one()
+
+ report = ContentReport(
+ content_hash=body.content_hash,
+ group_id=body.group_id,
+ reason=body.reason,
+ detail=body.detail,
+ ip_address=ip,
+ )
+ db.add(report)
+
+ action = "logged"
+ if count + 1 >= AUTO_BLOCK_THRESHOLD:
+ # Check if already blocked
+ existing = await db.get(ContentBlocklist, body.content_hash)
+ if not existing:
+ db.add(ContentBlocklist(
+ content_hash=body.content_hash,
+ reason=f"auto:{body.reason}",
+ added_by="auto",
+ ))
+ action = "auto_blocked"
+ log.warning("Content auto-blocked after %d reports: %s", count + 1, body.content_hash[:16])
+
+ await db.commit()
+ return {
+ "status": action,
+ "content_hash": body.content_hash,
+ "report_count": count + 1,
+ "threshold": AUTO_BLOCK_THRESHOLD,
+ }
+
+
+@router.get("/v1/blocklist/check")
+async def check_blocklist(
+ hash: str,
+ db: AsyncSession = Depends(get_db),
+):
+ """Check if a single hash is blocked. Used by nodes before serving public content."""
+ blocked = await db.get(ContentBlocklist, hash)
+ return {
+ "blocked": blocked is not None,
+ "hash": hash,
+ "reason": blocked.reason if blocked else None,
+ }
+
+
+@router.get("/v1/blocklist")
+async def get_blocklist(
+ db: AsyncSession = Depends(get_db),
+ limit: int = 10000,
+):
+ """
+ Return the full blocklist. Nodes sync this on startup.
+ Returns hashes only (not reasons) to minimize data exposure.
+ """
+ result = await db.execute(
+ select(ContentBlocklist.content_hash)
+ .order_by(ContentBlocklist.added_at.desc())
+ .limit(limit)
+ )
+ hashes = [row[0] for row in result.fetchall()]
+ return {"count": len(hashes), "hashes": hashes}
+
+
+# ── Admin endpoints ───────────────────────────────────────────────────────────
+
+@router.get("/v1/admin/blocklist")
+async def admin_list_blocklist(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+ limit: int = 500,
+):
+ result = await db.execute(
+ select(ContentBlocklist)
+ .order_by(ContentBlocklist.added_at.desc())
+ .limit(limit)
+ )
+ entries = result.scalars().all()
+ return {
+ "entries": [
+ {
+ "hash": e.content_hash,
+ "reason": e.reason,
+ "added_at": e.added_at.isoformat(),
+ "added_by": e.added_by,
+ }
+ for e in entries
+ ]
+ }
+
+
+@router.post("/v1/admin/blocklist", status_code=201)
+async def admin_add_blocklist(
+ body: BlocklistAddRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ existing = await db.get(ContentBlocklist, body.content_hash)
+ if existing:
+ raise HTTPException(status_code=409, detail="Hash already blocked")
+
+ db.add(ContentBlocklist(
+ content_hash=body.content_hash,
+ reason=body.reason,
+ added_by=current_user.username,
+ ))
+ await db.commit()
+ return {"status": "blocked", "hash": body.content_hash}
+
+
+@router.delete("/v1/admin/blocklist/{content_hash}", status_code=200)
+async def admin_remove_blocklist(
+ content_hash: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ entry = await db.get(ContentBlocklist, content_hash)
+ if not entry:
+ raise HTTPException(status_code=404, detail="Hash not in blocklist")
+ await db.delete(entry)
+ await db.commit()
+ return {"status": "unblocked", "hash": content_hash}
+
+
+def _ip(request: Request) -> str:
+ fwd = request.headers.get("X-Forwarded-For")
+ return fwd.split(",")[0].strip() if fwd else (
+ request.client.host if request.client else "unknown")
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 0254d08..5b01e80 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -24,7 +24,8 @@ from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.users import router as users_router, set_config as users_set_config
from meshbay_hub.api.nodes import router as nodes_router
from meshbay_hub.api.groups import router as groups_router
-from meshbay_hub.api.revocation import router as revocation_router
+from meshbay_hub.api.revocation import router as revocation_router
+from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.webapp import router as webapp_router
from meshbay_hub.api.middleware import limiter
@@ -67,6 +68,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(nodes_router)
app.include_router(groups_router)
app.include_router(revocation_router)
+ app.include_router(moderation_router)
app.include_router(webapp_router)
return app
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 3814f2e..63cc8c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -142,6 +142,41 @@ class RefreshToken(Base):
# ── IP logs (legal compliance) ────────────────────────────────────────────────
+class ContentReport(Base):
+ """
+ Report of a public content hash for moderation.
+ Two reports → automatic suspension. Third → admin review needed.
+ """
+ __tablename__ = "content_reports"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ reporter_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
+ content_hash: Mapped[str] = mapped_column(String(64), nullable=False) # blake3 hex
+ group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id"))
+ reason: Mapped[str] = mapped_column(String(32), default="illegal")
+ detail: Mapped[str | None] = mapped_column(String(256))
+ ip_address: Mapped[str] = mapped_column(String(45), nullable=False)
+ reported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+
+ __table_args__ = (
+ Index("ix_content_reports_hash", "content_hash"),
+ Index("ix_content_reports_group", "group_id"),
+ )
+
+
+class ContentBlocklist(Base):
+ """
+ Hash-based blocklist for public content.
+ Populated automatically after threshold reports, or manually by admins.
+ """
+ __tablename__ = "content_blocklist"
+
+ content_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
+ reason: Mapped[str] = mapped_column(String(64), nullable=False)
+ added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ added_by: Mapped[str | None] = mapped_column(String(64)) # "auto" or admin username
+
+
class IPLog(Base):
"""
Connection log for legal compliance.
diff --git a/packages/meshbay-hub/tests/test_moderation.py b/packages/meshbay-hub/tests/test_moderation.py
new file mode 100644
index 0000000..68e08c9
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_moderation.py
@@ -0,0 +1,95 @@
+"""Tests for moderation — reports + blocklist."""
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from meshbay_common.crypto import pk_to_b64
+
+
+FAKE_HASH = "a" * 64 # valid blake3 hex
+
+
+@pytest.fixture
+async def auth_headers(client):
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ await client.post("/v1/users/register", json={
+ "username": "mod_admin", "email": "m@t.com", "password": "modpass99",
+ "pk_user_ed25519": pk_to_b64(sk_ed.public_key()),
+ "pk_user_x25519": pk_to_b64(sk_x.public_key()),
+ })
+ r = await client.post("/v1/users/login",
+ json={"username": "mod_admin", "password": "modpass99"})
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+
+@pytest.mark.asyncio
+async def test_report_content_logged(client):
+ r = await client.post("/v1/reports", json={
+ "content_hash": FAKE_HASH, "reason": "illegal"})
+ assert r.status_code == 201
+ data = r.json()
+ assert data["report_count"] == 1
+ assert data["status"] == "logged"
+
+
+@pytest.mark.asyncio
+async def test_auto_block_on_threshold(client):
+ """Second report triggers auto-block."""
+ hash2 = "b" * 64
+ await client.post("/v1/reports", json={"content_hash": hash2, "reason": "spam"})
+ r = await client.post("/v1/reports", json={"content_hash": hash2, "reason": "spam"})
+ assert r.json()["status"] == "auto_blocked"
+ assert r.json()["report_count"] == 2
+
+
+@pytest.mark.asyncio
+async def test_blocklist_check(client):
+ hash3 = "c" * 64
+ # Not blocked yet
+ r = await client.get(f"/v1/blocklist/check?hash={hash3}")
+ assert r.json()["blocked"] is False
+
+ # Report twice to auto-block
+ await client.post("/v1/reports", json={"content_hash": hash3, "reason": "illegal"})
+ await client.post("/v1/reports", json={"content_hash": hash3, "reason": "illegal"})
+
+ r = await client.get(f"/v1/blocklist/check?hash={hash3}")
+ assert r.json()["blocked"] is True
+
+
+@pytest.mark.asyncio
+async def test_admin_add_remove_blocklist(client, auth_headers):
+ hash4 = "d" * 64
+
+ r = await client.post("/v1/admin/blocklist",
+ json={"content_hash": hash4, "reason": "csam"},
+ headers=auth_headers)
+ assert r.status_code == 201
+
+ r = await client.get(f"/v1/blocklist/check?hash={hash4}")
+ assert r.json()["blocked"] is True
+
+ r = await client.delete(f"/v1/admin/blocklist/{hash4}", headers=auth_headers)
+ assert r.status_code == 200
+
+ r = await client.get(f"/v1/blocklist/check?hash={hash4}")
+ assert r.json()["blocked"] is False
+
+
+@pytest.mark.asyncio
+async def test_invalid_hash_rejected(client):
+ r = await client.post("/v1/reports", json={
+ "content_hash": "not-a-valid-blake3-hash", "reason": "test"})
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_full_blocklist(client, auth_headers):
+ hash5 = "e" * 64
+ await client.post("/v1/admin/blocklist",
+ json={"content_hash": hash5, "reason": "test"},
+ headers=auth_headers)
+ r = await client.get("/v1/blocklist")
+ assert r.status_code == 200
+ assert hash5 in r.json()["hashes"]