aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
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 /packages/meshbay-hub/src/meshbay_hub/api/moderation.py
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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/moderation.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py200
1 files changed, 200 insertions, 0 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")