""" 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= — 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, require_admin from meshbay_hub.api.netutil import client_ip 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 = client_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(require_admin), 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(require_admin), 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(require_admin), 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}