diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:17:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:17:28 +0200 |
| commit | 42556800d103ede20b4e97f2d91d20bbc0000c1e (patch) | |
| tree | 1a392221995f4e8092bd20fb3acdd95a28c1d1f1 /packages/meshbay-hub/src/meshbay_hub/api/moderation.py | |
| parent | 1734c668406c66e2be63e0c6999b4b2af2f60808 (diff) | |
| download | meshbay-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.py | 200 |
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") |