diff options
Diffstat (limited to 'packages/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 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_moderation.py | 95 |
4 files changed, 333 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. diff --git a/packages/meshbay-hub/tests/test_moderation.py b/packages/meshbay-hub/tests/test_moderation.py new file mode 100644 index 0000000..68e08c9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_moderation.py @@ -0,0 +1,95 @@ +"""Tests for moderation — reports + blocklist.""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from meshbay_common.crypto import pk_to_b64 + + +FAKE_HASH = "a" * 64 # valid blake3 hex + + +@pytest.fixture +async def auth_headers(client): + sk_ed = Ed25519PrivateKey.generate() + sk_x = X25519PrivateKey.generate() + await client.post("/v1/users/register", json={ + "username": "mod_admin", "email": "m@t.com", "password": "modpass99", + "pk_user_ed25519": pk_to_b64(sk_ed.public_key()), + "pk_user_x25519": pk_to_b64(sk_x.public_key()), + }) + r = await client.post("/v1/users/login", + json={"username": "mod_admin", "password": "modpass99"}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +@pytest.mark.asyncio +async def test_report_content_logged(client): + r = await client.post("/v1/reports", json={ + "content_hash": FAKE_HASH, "reason": "illegal"}) + assert r.status_code == 201 + data = r.json() + assert data["report_count"] == 1 + assert data["status"] == "logged" + + +@pytest.mark.asyncio +async def test_auto_block_on_threshold(client): + """Second report triggers auto-block.""" + hash2 = "b" * 64 + await client.post("/v1/reports", json={"content_hash": hash2, "reason": "spam"}) + r = await client.post("/v1/reports", json={"content_hash": hash2, "reason": "spam"}) + assert r.json()["status"] == "auto_blocked" + assert r.json()["report_count"] == 2 + + +@pytest.mark.asyncio +async def test_blocklist_check(client): + hash3 = "c" * 64 + # Not blocked yet + r = await client.get(f"/v1/blocklist/check?hash={hash3}") + assert r.json()["blocked"] is False + + # Report twice to auto-block + await client.post("/v1/reports", json={"content_hash": hash3, "reason": "illegal"}) + await client.post("/v1/reports", json={"content_hash": hash3, "reason": "illegal"}) + + r = await client.get(f"/v1/blocklist/check?hash={hash3}") + assert r.json()["blocked"] is True + + +@pytest.mark.asyncio +async def test_admin_add_remove_blocklist(client, auth_headers): + hash4 = "d" * 64 + + r = await client.post("/v1/admin/blocklist", + json={"content_hash": hash4, "reason": "csam"}, + headers=auth_headers) + assert r.status_code == 201 + + r = await client.get(f"/v1/blocklist/check?hash={hash4}") + assert r.json()["blocked"] is True + + r = await client.delete(f"/v1/admin/blocklist/{hash4}", headers=auth_headers) + assert r.status_code == 200 + + r = await client.get(f"/v1/blocklist/check?hash={hash4}") + assert r.json()["blocked"] is False + + +@pytest.mark.asyncio +async def test_invalid_hash_rejected(client): + r = await client.post("/v1/reports", json={ + "content_hash": "not-a-valid-blake3-hash", "reason": "test"}) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_full_blocklist(client, auth_headers): + hash5 = "e" * 64 + await client.post("/v1/admin/blocklist", + json={"content_hash": hash5, "reason": "test"}, + headers=auth_headers) + r = await client.get("/v1/blocklist") + assert r.status_code == 200 + assert hash5 in r.json()["hashes"] |