aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-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
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.