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 | |
| 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')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/moderation.py | 200 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/app.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/db/models.py | 35 |
3 files changed, 238 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. |