aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/moderation.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py318
1 files changed, 222 insertions, 96 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
index 35c688c..038f310 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -1,59 +1,88 @@
"""
-MeshBay Hub — moderation endpoints.
+MeshBay Hub — moderation endpoints (docs/MESHBAY_DESIGN.md §7.5).
-Reporting flow:
- POST /v1/reports — report a content hash (sign-in required)
+Reporting:
+ POST /v1/reports — a member of a public group reports a file they saw there.
- Thresholds (counted as DISTINCT reporting accounts, not raw rows):
- < AUTO_BLOCK_THRESHOLD distinct reporters → logged
- >= AUTO_BLOCK_THRESHOLD distinct reporters → hash added to the blocklist
+ Who may: a person's account (never a node's token), old enough
+ (`reports.min_account_age_hours`), an active member of that public group,
+ within a daily allowance (`reports.daily_per_account`) as well as the per-address
+ rate limit. One report per account per hash.
+
+ What it leads to: once `reports.review_threshold` distinct accounts have
+ reported a hash, it is queued for an administrator (`content_reviews`), who is
+ notified and blocks or dismisses it. With `reports.auto_block` on, it is blocked
+ at once instead — the instance's choice, off by default, because a handful of
+ accounts made for the purpose would then be enough to take a file down.
The flow only runs while the hub brokers public content: with public groups
- switched off instance-wide there is nothing here to serve a reported hash from,
- so it is refused rather than left open as an unauthenticated write surface.
+ switched off there is nothing here to report.
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
+ GET /v1/admin/reports — hashes waiting for a decision
+ POST /v1/admin/reports/{hash}/block — block it, and tell the nodes
+ POST /v1/admin/reports/{hash}/dismiss — close it without blocking
+ 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)
+ GET /v1/blocklist?after=<hash> — the list, paged, for a node's own token
+ WebSocket `blocklist_update` — additions and removals, pushed to nodes
+ hosting a public group
"""
import logging
+from collections import Counter
+from datetime import UTC, datetime, timedelta
+from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import hub_settings
-from meshbay_hub.api.deps import get_current_user, require_admin
+from meshbay_hub.api.deps import (
+ require_admin,
+ require_moderator,
+ require_node_scope,
+ require_user_scope,
+ user_is_admin,
+)
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
+from meshbay_hub.api.revocation import broadcast_blocklist_update
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import ContentBlocklist, ContentReport, User
+from meshbay_hub.db.models import (
+ ContentBlocklist,
+ ContentReport,
+ ContentReview,
+ Group,
+ GroupMember,
+ User,
+)
log = logging.getLogger(__name__)
router = APIRouter(tags=["moderation"])
-# Distinct reporting accounts before a hash is auto-blocked. Kept low for a
-# responsive community signal, but note it is only as strong as account
-# creation: while a bot can register freely (see the reCAPTCHA gap), the real
-# control is the admin reviewing `GET /v1/admin/blocklist` and the audit log.
-AUTO_BLOCK_THRESHOLD = 3
+# One answer for every reason a report is not accepted from this account for this
+# group, so the endpoint does not tell anyone which groups exist or who is in them.
+_NOT_YOURS = "You can report a file only in a public group you are a member of."
+
+
+def _is_hash(value: str) -> bool:
+ return len(value) == 64 and all(c in "0123456789abcdef" for c in value)
# ── Models ────────────────────────────────────────────────────────────────────
class ReportRequest(BaseModel):
- content_hash: str # blake3 hex (64 chars)
- group_id: str | None = None
- reason: str = "illegal"
- detail: str | None = None
+ content_hash: str = Field(max_length=64) # blake3 hex (64 chars)
+ group_id: str = Field(max_length=36)
+ reason: Literal["illegal", "spam", "copyright", "other"] = "illegal"
+ detail: str | None = Field(default=None, max_length=256)
class BlocklistAddRequest(BaseModel):
@@ -61,124 +90,150 @@ class BlocklistAddRequest(BaseModel):
reason: str
-# ── Public endpoints ──────────────────────────────────────────────────────────
+# ── Reporting ─────────────────────────────────────────────────────────────────
@router.post("/v1/reports", status_code=201)
@limiter.limit("10/hour")
async def report_content(
body: ReportRequest,
request: Request,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
"""
- Report a public content hash for moderation.
+ Report a file of a public group, as a member of that group.
- Sign-in is required. It used to be anonymous, which made it a censorship
- primitive: two unauthenticated POSTs naming any blake3 id auto-added it to
- the blocklist that nodes enforce, network-wide, with manual admin removal the
- only undo. The threshold now counts *distinct reporting accounts*, one vote
- per account per hash.
-
- Refused entirely when the hub has public groups switched off: nothing here
- brokers public content then, nothing syncs the blocklist, and an open write
- endpoint would only be abuse surface.
+ Every bound here answers what a report costs someone else: a file taken out
+ of a group everyone else uses, and an administrator's time. So a report takes
+ a person's account (a node's token is refused), one that has existed for a
+ while, membership of the public group the file was seen in, and a daily
+ allowance per account besides the rate limit per address — an address is one
+ of thousands a subscriber holds. It never blocks anything by itself unless the
+ instance chose automatic blocking: past the threshold, an administrator
+ decides.
"""
if not await hub_settings.public_groups_allowed(db):
raise HTTPException(
status_code=403,
detail="This hub does not broker public content, so there is nothing to report here.")
-
- if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash):
+ if not _is_hash(body.content_hash):
raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
+ limits = await hub_settings.report_limits(db)
+ now = datetime.now(UTC)
+
+ created = current_user.created_at
+ if created is not None and created.tzinfo is None:
+ created = created.replace(tzinfo=UTC)
+ if created is not None and \
+ now - created < timedelta(hours=limits["min_account_age_hours"]):
+ raise HTTPException(status_code=403,
+ detail="This account is too new to report content yet.")
+
+ group = await db.get(Group, body.group_id)
+ member = await db.scalar(select(GroupMember.user_id).where(
+ GroupMember.group_id == body.group_id,
+ GroupMember.user_id == current_user.id))
+ if group is None or group.visibility != "public" or group.status != "active" \
+ or member is None:
+ raise HTTPException(status_code=403, detail=_NOT_YOURS)
+
+ today = await db.scalar(select(func.count(ContentReport.id)).where(
+ ContentReport.reporter_id == current_user.id,
+ ContentReport.reported_at > now - timedelta(days=1))) or 0
+ if today >= limits["daily_per_account"]:
+ raise HTTPException(status_code=429,
+ detail="You have reached today's number of reports.")
+
# One vote per account per hash — a single reporter must not be able to walk
# the threshold up on their own by posting repeatedly.
already = await db.scalar(
select(ContentReport.id).where(
ContentReport.content_hash == body.content_hash,
ContentReport.reporter_id == current_user.id))
+ if already:
+ return {"status": "already_reported"}
- if not already:
- db.add(ContentReport(
- content_hash=body.content_hash,
- reporter_id=current_user.id,
- group_id=body.group_id,
- reason=body.reason,
- detail=body.detail,
- ip_address=client_ip(request),
- ))
- await db.flush()
+ db.add(ContentReport(
+ content_hash=body.content_hash,
+ reporter_id=current_user.id,
+ group_id=body.group_id,
+ reason=body.reason,
+ detail=body.detail,
+ ip_address=client_ip(request),
+ ))
+ await db.flush()
distinct_reporters = await db.scalar(
select(func.count(func.distinct(ContentReport.reporter_id)))
.where(ContentReport.content_hash == body.content_hash)) or 0
- action = "already_reported" if already else "logged"
- if distinct_reporters >= AUTO_BLOCK_THRESHOLD:
- 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"
+ blocked_now = False
+ if distinct_reporters >= limits["review_threshold"] \
+ and await db.get(ContentBlocklist, body.content_hash) is None:
+ review = await db.get(ContentReview, body.content_hash)
+ if review is not None and review.status == "dismissed":
+ pass # an administrator's decision stands; more reports do not reopen it
+ elif limits["auto_block"]:
+ db.add(ContentBlocklist(content_hash=body.content_hash,
+ reason=f"auto:{body.reason}", added_by="auto"))
+ if review is None:
+ db.add(ContentReview(content_hash=body.content_hash, status="blocked",
+ decided_at=now, decided_by="auto"))
+ else:
+ review.status, review.decided_at, review.decided_by = "blocked", now, "auto"
+ blocked_now = True
log.warning("Content auto-blocked after %d distinct reporters: %s",
distinct_reporters, body.content_hash[:16])
-
+ elif review is None:
+ db.add(ContentReview(content_hash=body.content_hash, status="pending"))
+ await _notify_admins(db, body.content_hash)
+ log.warning("Content queued for review after %d distinct reporters: %s",
+ distinct_reporters, body.content_hash[:16])
await db.commit()
- return {
- "status": action,
- "content_hash": body.content_hash,
- "report_count": distinct_reporters,
- "threshold": AUTO_BLOCK_THRESHOLD,
- }
-
+ if blocked_now:
+ await broadcast_blocklist_update(db, add=[body.content_hash])
+ # The same answer whatever happened next: a reporter is not told how close a
+ # file is to review, which is a count to aim at.
+ return {"status": "logged"}
-@router.get("/v1/blocklist/check")
-@limiter.limit("120/minute")
-async def check_blocklist(
- hash: str,
- request: Request,
- db: AsyncSession = Depends(get_db),
-):
- """Check if a single hash is blocked. Used by nodes before serving public content.
- Unauthenticated, because a node consults it before serving public content
- and does so on its own behalf. That makes the shape check worth having:
- without it any string of any length became a primary-key lookup.
- """
- if len(hash) != 64 or not all(c in "0123456789abcdef" for c in hash):
- raise HTTPException(status_code=422, detail="hash must be 64 hex chars (blake3)")
- blocked = await db.get(ContentBlocklist, hash)
- return {
- "blocked": blocked is not None,
- "hash": hash,
- "reason": blocked.reason if blocked else None,
- }
+async def _notify_admins(db: AsyncSession, content_hash: str) -> None:
+ from meshbay_hub.api.notifications import create_notification
+ admins = [u for u in (await db.execute(select(User).where(
+ User.status == "active"))).scalars().all() if user_is_admin(u)]
+ for admin in admins:
+ await create_notification(
+ db, admin.id, "content_review",
+ "Reported content is waiting for a decision",
+ detail=content_hash[:16], link="#/admin", aggregate=False)
@router.get("/v1/blocklist")
async def get_blocklist(
+ current_node: User = Depends(require_node_scope),
db: AsyncSession = Depends(get_db),
- # Bounded, like every other list. This one takes no authentication — a
- # node syncs it at startup — and had no ceiling at all, so any stranger
- # could ask for the table in one query, repeatedly. 10 000 is what a node
- # asks for, so it is the default and also the most anyone may have.
+ after: str = Query(default="", max_length=64),
limit: int = Query(default=10000, ge=1, le=10000),
):
"""
- Return the full blocklist. Nodes sync this on startup.
- Returns hashes only (not reasons) to minimize data exposure.
+ The content blocklist, a page at a time, for a node hosting a public group.
+
+ Hashes only, never the reasons. Ordered by hash so `after` (the last hash of
+ the previous page) is a stable cursor: a list longer than one page used to
+ be cut at 10 000 with no way to ask for the rest, and the node applying it
+ silently served everything past the cut. A node's own token, because this
+ is what a node fetches on its own behalf and nothing else asks for it.
"""
result = await db.execute(
select(ContentBlocklist.content_hash)
- .order_by(ContentBlocklist.added_at.desc())
+ .where(ContentBlocklist.content_hash > after)
+ .order_by(ContentBlocklist.content_hash)
.limit(limit)
)
hashes = [row[0] for row in result.fetchall()]
- return {"count": len(hashes), "hashes": hashes}
+ return {"hashes": hashes,
+ "next": hashes[-1] if len(hashes) == limit else None}
# ── Admin endpoints ───────────────────────────────────────────────────────────
@@ -214,16 +269,19 @@ async def admin_add_blocklist(
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
+ if not _is_hash(body.content_hash):
+ raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
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,
+ reason=body.reason[:64],
added_by=current_user.username,
))
await db.commit()
+ await broadcast_blocklist_update(db, add=[body.content_hash])
return {"status": "blocked", "hash": body.content_hash}
@@ -238,5 +296,73 @@ async def admin_remove_blocklist(
raise HTTPException(status_code=404, detail="Hash not in blocklist")
await db.delete(entry)
await db.commit()
+ await broadcast_blocklist_update(db, remove=[content_hash])
return {"status": "unblocked", "hash": content_hash}
+
+# ── Review queue ──────────────────────────────────────────────────────────────
+
+@router.get("/v1/admin/reports")
+async def admin_list_reports(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+ limit: int = Query(default=100, ge=1, le=500),
+):
+ """Hashes waiting for a decision, oldest first, with what was said about them."""
+ reviews = (await db.execute(
+ select(ContentReview).where(ContentReview.status == "pending")
+ .order_by(ContentReview.opened_at).limit(limit))).scalars().all()
+ out = []
+ for r in reviews:
+ reports = (await db.execute(select(ContentReport).where(
+ ContentReport.content_hash == r.content_hash))).scalars().all()
+ group_ids = sorted({x.group_id for x in reports if x.group_id})
+ names = dict((await db.execute(select(Group.id, Group.name).where(
+ Group.id.in_(group_ids)))).all()) if group_ids else {}
+ out.append({
+ "hash": r.content_hash,
+ "opened_at": r.opened_at.isoformat(),
+ "reporters": len({x.reporter_id for x in reports}),
+ "reasons": dict(Counter(x.reason for x in reports)),
+ "details": [x.detail for x in reports if x.detail][:10],
+ "groups": [{"id": g, "name": names.get(g, "")} for g in group_ids],
+ })
+ return {"reports": out}
+
+
+async def _decide(db: AsyncSession, content_hash: str, status: str, by: str) -> ContentReview:
+ review = await db.get(ContentReview, content_hash)
+ if review is None or review.status != "pending":
+ raise HTTPException(status_code=404, detail="Nothing waiting for this hash")
+ review.status, review.decided_at, review.decided_by = status, datetime.now(UTC), by
+ return review
+
+
+@router.post("/v1/admin/reports/{content_hash}/block")
+async def admin_block_reported(
+ content_hash: str,
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ await _decide(db, content_hash, "blocked", current_user.username)
+ reasons = Counter((await db.execute(select(ContentReport.reason).where(
+ ContentReport.content_hash == content_hash))).scalars().all())
+ if await db.get(ContentBlocklist, content_hash) is None:
+ db.add(ContentBlocklist(
+ content_hash=content_hash,
+ reason=f"reported:{reasons.most_common(1)[0][0] if reasons else 'other'}",
+ added_by=current_user.username))
+ await db.commit()
+ await broadcast_blocklist_update(db, add=[content_hash])
+ return {"status": "blocked", "hash": content_hash}
+
+
+@router.post("/v1/admin/reports/{content_hash}/dismiss")
+async def admin_dismiss_reported(
+ content_hash: str,
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ await _decide(db, content_hash, "dismissed", current_user.username)
+ await db.commit()
+ return {"status": "dismissed", "hash": content_hash}