diff options
Diffstat (limited to 'packages')
21 files changed, 908 insertions, 113 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 381378c..dbda197 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -43,6 +43,8 @@ class SettingsPatchRequest(BaseModel): login: dict[str, int] | None = None # Session lifetime, in hours, each optional. session: dict[str, int] | None = None + # Content reports: who may report, how often, what a report leads to. + reports: dict[str, int] | None = None # ── Instance settings ──────────────────────────────────────────────────────── @@ -63,6 +65,9 @@ async def _settings_payload(db: AsyncSession) -> dict: "session": await hub_settings.session_limits(db), "session_defaults": dict(hub_settings.SESSION_DEFAULTS), "session_bounds": {k: list(v) for k, v in hub_settings.SESSION_BOUNDS.items()}, + "reports": await hub_settings.report_limits(db), + "reports_defaults": dict(hub_settings.REPORT_DEFAULTS), + "reports_bounds": {k: list(v) for k, v in hub_settings.REPORT_BOUNDS.items()}, } @@ -162,6 +167,26 @@ async def admin_patch_settings( )) await db.commit() + if body.reports: + unknown = sorted(set(body.reports) - set(hub_settings.REPORT_KEYS)) + if unknown: + raise HTTPException( + status_code=422, detail=f"Unknown report setting(s): {unknown}") + changed = [] + for key, value in body.reports.items(): + clamped = hub_settings.clamp_report_value(key, value) + await hub_settings.set_raw(db, f"reports.{key}", str(clamped)) + changed.append(f"{key}={clamped}") + log.info("Report policy changed by %s: %s", + current_user.username, ", ".join(changed)) + db.add(IPLog( + user_id=current_user.id, + event="admin_reports_update", + ip_address="admin", + detail=", ".join(changed)[:255], + )) + await db.commit() + return await _settings_payload(db) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py index c4e6af5..038f310 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py @@ -1,21 +1,30 @@ """ -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?after=<hash> — the list, paged, for a node's own token @@ -24,29 +33,43 @@ Node integration: """ 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, require_node_scope +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: @@ -56,10 +79,10 @@ def _is_hash(value: str) -> bool: # ── 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): @@ -67,83 +90,123 @@ 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. - - 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. + Report a file of a public group, as a member of that group. - 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 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" - auto_blocked = False - 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" - auto_blocked = True + 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() - if auto_blocked: + if blocked_now: await broadcast_blocklist_update(db, add=[body.content_hash]) - return { - "status": action, - "content_hash": body.content_hash, - "report_count": distinct_reporters, - "threshold": AUTO_BLOCK_THRESHOLD, - } + # 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"} + + +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") @@ -236,3 +299,70 @@ async def admin_remove_blocklist( 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} diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f7a8b9c0d1e2_add_content_reviews.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f7a8b9c0d1e2_add_content_reviews.py new file mode 100644 index 0000000..18466b8 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f7a8b9c0d1e2_add_content_reviews.py @@ -0,0 +1,35 @@ +"""content reports wait for an administrator + +A hash reported by enough distinct accounts is queued for review instead of +being blocked on the spot, unless the instance chose automatic blocking. + +Revision ID: f7a8b9c0d1e2 +Revises: e6f7a8b9c0d1 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "f7a8b9c0d1e2" +down_revision: str | Sequence[str] | None = "e6f7a8b9c0d1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "content_reviews", + sa.Column("content_hash", sa.String(64), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("decided_by", sa.String(64), nullable=True), + sa.PrimaryKeyConstraint("content_hash"), + ) + + +def downgrade() -> None: + op.drop_table("content_reviews") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 288f1e7..5b140f1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -325,6 +325,24 @@ class ContentBlocklist(Base): added_by: Mapped[str | None] = mapped_column(String(64)) # "auto" or admin username +class ContentReview(Base): + """ + A reported hash waiting for — or given — an administrator's decision. + + Opened when enough distinct accounts have reported it; `status` is + `pending`, then `blocked` or `dismissed`. A dismissed hash stays dismissed: + more reports of it do not reopen it, and an administrator can still block + it from the blocklist. The reports themselves stay in `content_reports`. + """ + __tablename__ = "content_reviews" + + content_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + status: Mapped[str] = mapped_column(String(16), default="pending") + opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + decided_by: Mapped[str | None] = mapped_column(String(64)) + + class UserPreference(Base): __tablename__ = "user_preferences" diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py index e01bfd2..d14cc0c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py +++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py @@ -158,6 +158,42 @@ async def session_limits(db: AsyncSession) -> dict[str, int]: for k in SESSION_KEYS} +# ── Content reports ────────────────────────────────────────────────────────── +# +# Who may report public content, how often, and what a report leads to +# (docs/MESHBAY_DESIGN.md §7.5). `auto_block` is 0 or 1: off, a hash that +# reaches the threshold waits for an administrator; on, it is blocked at once — +# three accounts made for the purpose would then be enough to take a file down. + +REPORT_KEYS = ("min_account_age_hours", "daily_per_account", + "review_threshold", "auto_block") + +REPORT_DEFAULTS: dict[str, int] = { + "min_account_age_hours": 24, + "daily_per_account": 20, + "review_threshold": 3, + "auto_block": 0, +} + +REPORT_BOUNDS: dict[str, tuple[int, int]] = { + "min_account_age_hours": (0, 720), # 30 days + "daily_per_account": (1, 1_000), + "review_threshold": (1, 100), + "auto_block": (0, 1), +} + + +def clamp_report_value(key: str, value: int) -> int: + low, high = REPORT_BOUNDS[key] + return max(low, min(high, int(value))) + + +async def report_limits(db: AsyncSession) -> dict[str, int]: + return {k: clamp_report_value( + k, await get_int(db, f"reports.{k}", REPORT_DEFAULTS[k])) + for k in REPORT_KEYS} + + async def get_raw(db: AsyncSession, key: str) -> str | None: row = await db.get(HubSetting, key) return row.value if row else None diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js index 8cbfb5a..9c32475 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -28,6 +28,8 @@ export function AdminPage({ token, role }) { const [logEvent, setLogEvent] = useState(''); const [logOffset, setLogOffset] = useState(0); const [blocklist, setBlocklist] = useState([]); + const [reports, setReports] = useState([]); + const [reportsDraft, setReportsDraft] = useState(null); const [nodes, setNodes] = useState([]); const [detailUser, setDetailUser] = useState(null); const [error, setError] = useState(''); @@ -48,6 +50,7 @@ export function AdminPage({ token, role }) { setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); setSessionDraft({ ...data.session }); + setReportsDraft({ ...data.reports }); } catch (e) { setError(e.message); } try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); @@ -68,6 +71,7 @@ export function AdminPage({ token, role }) { setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); setSessionDraft({ ...data.session }); + setReportsDraft({ ...data.reports }); if (patch.mail) { try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); @@ -102,6 +106,23 @@ export function AdminPage({ token, role }) { } catch (e) { setError(e.message); } }, [token]); + const loadReports = useCallback(async () => { + try { + const data = await hubFetch('/v1/admin/reports', { token }); + setReports(data.reports); + } catch (e) { setError(e.message); } + }, [token]); + + // Block or dismiss one reported hash. Blocking names it on the list every + // node hosting a public group applies; dismissing closes it for good. + const decideReport = useCallback(async (hash, verdict) => { + if (verdict === 'block' && !await ask(t('admin.report_block_confirm'))) return; + try { + await hubFetch(`/v1/admin/reports/${hash}/${verdict}`, { method: 'POST', token }); + loadReports(); + } catch (e) { setError(e.message); } + }, [token]); + const loadBlocklist = useCallback(async () => { try { const data = await hubFetch('/v1/admin/blocklist', { token }); @@ -124,6 +145,7 @@ export function AdminPage({ token, role }) { .then(d => setNodes(d.nodes || [])).catch(e => setError(e.message)); } else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); } + else if (tab === 'reports') loadReports(); else if (tab === 'blocklist') loadBlocklist(); }, [tab]); @@ -205,13 +227,16 @@ const LOGIN_FIELDS = ['max_failures', 'lockout_minutes']; const SESSION_FIELDS = ['browser_idle_hours', 'refresh_idle_hours', 'max_hours']; +const REPORT_FIELDS = ['min_account_age_hours', 'daily_per_account', 'review_threshold', + 'auto_block']; + // Only what changed, and only what is a number: an empty field is someone // mid-edit, not a request to set zero. const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields .filter(k => draft[k] !== '' && draft[k] !== null && Number(draft[k]) !== stored[k]) .map(k => [k, Number(draft[k])])); -const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; +const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'reports', 'blocklist']; const canEditSettings = role === 'admin'; return html` @@ -304,6 +329,37 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist `} </div> + ${settings.reports && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('admin.reports_heading')}</h3> + <p class="settings-hint">${t('admin.reports_hint')}</p> + + ${reportsDraft && REPORT_FIELDS.map(key => html` + <div class="settings-row" key=${key}> + <span class="settings-label">${t('admin.reports_' + key)}</span> + <input type="number" class="settings-number" + min=${(settings.reports_bounds?.[key] || [0])[0]} + max=${(settings.reports_bounds?.[key] || [0, 0])[1]} + value=${reportsDraft[key]} + disabled=${!canEditSettings || settingsSaving} + onInput=${e => setReportsDraft(d => ({ ...d, [key]: e.target.value }))} /> + </div> + `)} + + ${canEditSettings && reportsDraft && html` + <div class="settings-row"> + <button class="btn" disabled=${settingsSaving} + onClick=${() => saveSettings({ + reports: changedNumbers(REPORT_FIELDS, reportsDraft, settings.reports), + })}>${t('admin.reports_save')}</button> + <button class="btn btn-secondary" disabled=${settingsSaving} + onClick=${() => setReportsDraft({ ...settings.reports_defaults })} + >${t('admin.mail_reset_defaults')}</button> + </div> + `} + </div> + `} + <div class="settings-section"> <h3 class="settings-heading">${t('admin.session_heading')}</h3> <p class="settings-hint">${t('admin.session_hint')}</p> @@ -546,6 +602,40 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist `} `} + ${tab === 'reports' && html` + <table class="admin-table"> + <thead><tr> + <th>${t('admin.col_hash')}</th> + <th>${t('admin.col_reporters')}</th> + <th>${t('admin.col_reason')}</th> + <th>${t('admin.col_groups')}</th> + <th>${t('admin.col_date')}</th> + <th>${t('admin.col_actions')}</th> + </tr></thead> + <tbody> + ${reports.length === 0 && html`<tr><td colspan="6" class="admin-empty">${t('admin.no_reports')}</td></tr>`} + ${reports.map(r => html` + <tr key=${r.hash}> + <td style="font-family:monospace;font-size:0.8em">${r.hash.slice(0, 16)}...</td> + <td>${r.reporters}</td> + <td> + ${Object.entries(r.reasons).map(([k, n]) => `${t('report.reason_' + k)} (${n})`).join(', ')} + ${r.details.map(d => html`<div class="settings-hint">${d}</div>`)} + </td> + <td>${r.groups.map(g => g.name || g.id.slice(0, 8)).join(', ')}</td> + <td>${new Date(r.opened_at).toLocaleDateString()}</td> + <td> + ${role === 'admin' && html` + <button class="admin-btn" onClick=${() => decideReport(r.hash, 'block')}>${t('admin.btn_block')}</button> + <button class="admin-btn" onClick=${() => decideReport(r.hash, 'dismiss')}>${t('admin.btn_dismiss')}</button> + `} + </td> + </tr> + `)} + </tbody> + </table> + `} + ${tab === 'blocklist' && html` <${BlocklistForm} onAdd=${addToBlocklist} /> <table class="admin-table"> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 775e2e3..cda34b5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -2,7 +2,7 @@ import { html, useState, useEffect, useRef, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; -import { ask } from './ask.js'; +import { ask, tell } from './ask.js'; import { Icon } from './icon.js'; import { entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; @@ -12,6 +12,7 @@ import { } from './file-utils.js'; import { useStickyBand } from './sticky.js'; import { Menu, useMenu } from './menu.js'; +import { askReport } from './report.js'; // ── Files ──────────────────────────────────────────────────────────────────── // @@ -140,7 +141,7 @@ function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, userId, setError, onPreview, - showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, + showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport, }) { const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); @@ -668,6 +669,20 @@ function FilesPanel({ ? [[], [key.slice(4)]] : [entries.filter(x => x.id === key), []]; const items = actionsFor(files, dirs, selected.has(key)).filter(a => !a.disabled); + // One file at a time, and from the menu only: a report is about a file + // somebody looked at, not a batch action for a toolbar. + const one = files.length === 1 && dirs.length === 0 ? files[0] : null; + if (onReport && one) { + items.push({ key: 'report', icon: 'shield', label: t('report.action'), + onSelect: async () => { + const answer = await askReport(one.name); + if (!answer) return; + try { + await onReport(one.id, answer.reason, answer.detail); + await tell(t('report.sent')); + } catch (err) { setError(err.message); } + } }); + } if (items.length) openAt(e, items); }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 7c9b2f0..b6f3d37 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -834,8 +834,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, }), [perAppDirectories, chatDirectory, chatLinkPreview, tmdbConfig, musicbrainzConfig]); + // Reporting a file is offered in a public group only: that is the only place + // the hub's moderation reaches (docs/MESHBAY_DESIGN.md §7.5), and the hub + // refuses a report from anyone who is not a member of the group named here. + const isPublic = !!(group && group.visibility === 'public'); + const reportContent = useCallback((contentHash, reason, detail) => hubFetch( + '/v1/reports', { method: 'POST', token, + body: { content_hash: contentHash, group_id: groupId, reason, detail } }), + [groupId, token]); + const commonProps = { groupId, transportRef, gekRef, status, username, deviceReady, + onReport: isPublic ? reportContent : null, entries, availableEntries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 0212e53..b84da2b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1194,4 +1194,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'Abbrechen', + + // Content reports (report.js, admin-page.js) + 'report.action': "Melden", + 'report.title': "Diese Datei melden", + 'report.hint': "Ein Administrator dieses Hubs wird sie prüfen.", + 'report.reason_illegal': "Illegaler Inhalt", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Urheberrechtsverletzung", + 'report.reason_other': "Sonstiges", + 'report.detail_placeholder': "Details (optional)", + 'report.send': "Meldung senden", + 'report.sent': "Danke. Ihre Meldung wurde erfasst.", + 'admin.tab_reports': "Meldungen", + 'admin.no_reports': "Nichts wartet auf eine Entscheidung", + 'admin.col_reporters': "Meldende", + 'admin.btn_dismiss': "Verwerfen", + 'admin.report_block_confirm': "Diese Datei sperren? Jeder Knoten mit einer öffentlichen Gruppe stellt sie dort nicht mehr bereit.", + 'admin.reports_heading': "Inhaltsmeldungen", + 'admin.reports_hint': "Wer eine Datei in einer öffentlichen Gruppe melden darf, wie oft, und was geschieht, wenn genug Mitglieder es getan haben.", + 'admin.reports_min_account_age_hours': "Mindestalter des Kontos (Stunden)", + 'admin.reports_daily_per_account': "Meldungen pro Konto und Tag", + 'admin.reports_review_threshold': "Mitglieder bis zur Prüfung", + 'admin.reports_auto_block': "Ohne Prüfung sperren (1 = ja, 0 = nein)", + 'admin.reports_save': "Meldeeinstellungen speichern", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index ce6012c..7cbd830 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1175,4 +1175,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'Cancel', + + // Content reports (report.js, admin-page.js) + 'report.action': "Report", + 'report.title': "Report this file", + 'report.hint': "An administrator of this hub will review it.", + 'report.reason_illegal': "Illegal content", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Copyright infringement", + 'report.reason_other': "Other", + 'report.detail_placeholder': "Details (optional)", + 'report.send': "Send report", + 'report.sent': "Thank you. Your report has been recorded.", + 'admin.tab_reports': "Reports", + 'admin.no_reports': "Nothing is waiting for a decision", + 'admin.col_reporters': "Reporters", + 'admin.btn_dismiss': "Dismiss", + 'admin.report_block_confirm': "Block this file? Every node hosting a public group will stop serving it there.", + 'admin.reports_heading': "Content reports", + 'admin.reports_hint': "Who may report a file in a public group, how often, and what happens once enough members have.", + 'admin.reports_min_account_age_hours': "Minimum account age (hours)", + 'admin.reports_daily_per_account': "Reports per account per day", + 'admin.reports_review_threshold': "Members before review", + 'admin.reports_auto_block': "Block without review (1 = yes, 0 = no)", + 'admin.reports_save': "Save report settings", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 3ee45ac..081b1f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1188,4 +1188,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'Aceptar', 'dialog.cancel': 'Cancelar', + + // Content reports (report.js, admin-page.js) + 'report.action': "Denunciar", + 'report.title': "Denunciar este archivo", + 'report.hint': "Un administrador de este hub lo revisará.", + 'report.reason_illegal': "Contenido ilegal", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Infracción de derechos de autor", + 'report.reason_other': "Otro", + 'report.detail_placeholder': "Detalles (opcional)", + 'report.send': "Enviar denuncia", + 'report.sent': "Gracias. Su denuncia ha quedado registrada.", + 'admin.tab_reports': "Denuncias", + 'admin.no_reports': "Nada espera una decisión", + 'admin.col_reporters': "Denunciantes", + 'admin.btn_dismiss': "Descartar", + 'admin.report_block_confirm': "¿Bloquear este archivo? Todos los nodos que alojan un grupo público dejarán de servirlo allí.", + 'admin.reports_heading': "Denuncias de contenido", + 'admin.reports_hint': "Quién puede denunciar un archivo en un grupo público, con qué frecuencia y qué ocurre cuando suficientes miembros lo han hecho.", + 'admin.reports_min_account_age_hours': "Antigüedad mínima de la cuenta (horas)", + 'admin.reports_daily_per_account': "Denuncias por cuenta y día", + 'admin.reports_review_threshold': "Miembros antes de la revisión", + 'admin.reports_auto_block': "Bloquear sin revisión (1 = sí, 0 = no)", + 'admin.reports_save': "Guardar ajustes de denuncias", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 0ee20e3..04218d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1203,4 +1203,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'Annuler', + + // Content reports (report.js, admin-page.js) + 'report.action': "Signaler", + 'report.title': "Signaler ce fichier", + 'report.hint': "Un administrateur de ce hub l’examinera.", + 'report.reason_illegal': "Contenu illégal", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Atteinte au droit d’auteur", + 'report.reason_other': "Autre", + 'report.detail_placeholder': "Précisions (facultatif)", + 'report.send': "Envoyer le signalement", + 'report.sent': "Merci. Votre signalement a été enregistré.", + 'admin.tab_reports': "Signalements", + 'admin.no_reports': "Rien n’attend de décision", + 'admin.col_reporters': "Signalements reçus", + 'admin.btn_dismiss': "Écarter", + 'admin.report_block_confirm': "Bloquer ce fichier ? Chaque nœud qui héberge un groupe public cessera de le servir.", + 'admin.reports_heading': "Signalement de contenu", + 'admin.reports_hint': "Qui peut signaler un fichier dans un groupe public, à quelle fréquence, et ce qui se passe quand assez de membres l’ont fait.", + 'admin.reports_min_account_age_hours': "Âge minimal du compte (heures)", + 'admin.reports_daily_per_account': "Signalements par compte et par jour", + 'admin.reports_review_threshold': "Membres avant examen", + 'admin.reports_auto_block': "Bloquer sans examen (1 = oui, 0 = non)", + 'admin.reports_save': "Enregistrer les réglages de signalement", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index f1631cd..857bbc2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1202,4 +1202,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'Annulla', + + // Content reports (report.js, admin-page.js) + 'report.action': "Segnala", + 'report.title': "Segnala questo file", + 'report.hint': "Un amministratore di questo hub lo esaminerà.", + 'report.reason_illegal': "Contenuto illegale", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Violazione del diritto d’autore", + 'report.reason_other': "Altro", + 'report.detail_placeholder': "Dettagli (facoltativo)", + 'report.send': "Invia segnalazione", + 'report.sent': "Grazie. La tua segnalazione è stata registrata.", + 'admin.tab_reports': "Segnalazioni", + 'admin.no_reports': "Nulla attende una decisione", + 'admin.col_reporters': "Segnalanti", + 'admin.btn_dismiss': "Archivia", + 'admin.report_block_confirm': "Bloccare questo file? Ogni nodo che ospita un gruppo pubblico smetterà di servirlo lì.", + 'admin.reports_heading': "Segnalazioni di contenuti", + 'admin.reports_hint': "Chi può segnalare un file in un gruppo pubblico, quanto spesso e cosa succede quando abbastanza membri lo hanno fatto.", + 'admin.reports_min_account_age_hours': "Età minima dell’account (ore)", + 'admin.reports_daily_per_account': "Segnalazioni per account al giorno", + 'admin.reports_review_threshold': "Membri prima dell’esame", + 'admin.reports_auto_block': "Blocca senza esame (1 = sì, 0 = no)", + 'admin.reports_save': "Salva impostazioni segnalazioni", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 94e8f75..2d9830b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1186,4 +1186,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'キャンセル', + + // Content reports (report.js, admin-page.js) + 'report.action': "報告", + 'report.title': "このファイルを報告", + 'report.hint': "このハブの管理者が確認します。", + 'report.reason_illegal': "違法なコンテンツ", + 'report.reason_spam': "スパム", + 'report.reason_copyright': "著作権侵害", + 'report.reason_other': "その他", + 'report.detail_placeholder': "詳細(任意)", + 'report.send': "報告を送信", + 'report.sent': "ありがとうございます。報告を受け付けました。", + 'admin.tab_reports': "報告", + 'admin.no_reports': "判断待ちの項目はありません", + 'admin.col_reporters': "報告者数", + 'admin.btn_dismiss': "却下", + 'admin.report_block_confirm': "このファイルをブロックしますか?公開グループをホストするすべてのノードが、そこでの提供を停止します。", + 'admin.reports_heading': "コンテンツの報告", + 'admin.reports_hint': "公開グループのファイルを誰が、どのくらいの頻度で報告できるか、そして十分な数のメンバーが報告したときに何が起こるか。", + 'admin.reports_min_account_age_hours': "アカウントの最低経過時間(時間)", + 'admin.reports_daily_per_account': "1アカウントあたり1日の報告数", + 'admin.reports_review_threshold': "確認までのメンバー数", + 'admin.reports_auto_block': "確認せずにブロック(1 = はい、0 = いいえ)", + 'admin.reports_save': "報告の設定を保存", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index ef97f1f..8635995 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1204,4 +1204,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'Annuleren', + + // Content reports (report.js, admin-page.js) + 'report.action': "Melden", + 'report.title': "Dit bestand melden", + 'report.hint': "Een beheerder van deze hub bekijkt het.", + 'report.reason_illegal': "Illegale inhoud", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Inbreuk op auteursrecht", + 'report.reason_other': "Overig", + 'report.detail_placeholder': "Details (optioneel)", + 'report.send': "Melding versturen", + 'report.sent': "Dank u. Uw melding is vastgelegd.", + 'admin.tab_reports': "Meldingen", + 'admin.no_reports': "Niets wacht op een beslissing", + 'admin.col_reporters': "Melders", + 'admin.btn_dismiss': "Afwijzen", + 'admin.report_block_confirm': "Dit bestand blokkeren? Elke node met een openbare groep stopt met het daar aanbieden.", + 'admin.reports_heading': "Inhoudsmeldingen", + 'admin.reports_hint': "Wie een bestand in een openbare groep mag melden, hoe vaak, en wat er gebeurt als genoeg leden dat hebben gedaan.", + 'admin.reports_min_account_age_hours': "Minimale leeftijd van het account (uren)", + 'admin.reports_daily_per_account': "Meldingen per account per dag", + 'admin.reports_review_threshold': "Leden vóór beoordeling", + 'admin.reports_auto_block': "Blokkeren zonder beoordeling (1 = ja, 0 = nee)", + 'admin.reports_save': "Meldingsinstellingen opslaan", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 5a2257b..f988e5e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1230,4 +1230,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'Anuluj', + + // Content reports (report.js, admin-page.js) + 'report.action': "Zgłoś", + 'report.title': "Zgłoś ten plik", + 'report.hint': "Administrator tego huba go sprawdzi.", + 'report.reason_illegal': "Treść nielegalna", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Naruszenie praw autorskich", + 'report.reason_other': "Inne", + 'report.detail_placeholder': "Szczegóły (opcjonalnie)", + 'report.send': "Wyślij zgłoszenie", + 'report.sent': "Dziękujemy. Zgłoszenie zostało zapisane.", + 'admin.tab_reports': "Zgłoszenia", + 'admin.no_reports': "Nic nie czeka na decyzję", + 'admin.col_reporters': "Zgłaszający", + 'admin.btn_dismiss': "Odrzuć", + 'admin.report_block_confirm': "Zablokować ten plik? Każdy węzeł hostujący grupę publiczną przestanie go tam udostępniać.", + 'admin.reports_heading': "Zgłoszenia treści", + 'admin.reports_hint': "Kto może zgłosić plik w grupie publicznej, jak często i co się dzieje, gdy zrobi to wystarczająco wielu członków.", + 'admin.reports_min_account_age_hours': "Minimalny wiek konta (godziny)", + 'admin.reports_daily_per_account': "Zgłoszenia na konto dziennie", + 'admin.reports_review_threshold': "Członkowie przed oceną", + 'admin.reports_auto_block': "Blokuj bez oceny (1 = tak, 0 = nie)", + 'admin.reports_save': "Zapisz ustawienia zgłoszeń", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 65d2102..d761dd3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1189,4 +1189,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': 'OK', 'dialog.cancel': 'Cancelar', + + // Content reports (report.js, admin-page.js) + 'report.action': "Denunciar", + 'report.title': "Denunciar este arquivo", + 'report.hint': "Um administrador deste hub vai analisá-lo.", + 'report.reason_illegal': "Conteúdo ilegal", + 'report.reason_spam': "Spam", + 'report.reason_copyright': "Violação de direitos autorais", + 'report.reason_other': "Outro", + 'report.detail_placeholder': "Detalhes (opcional)", + 'report.send': "Enviar denúncia", + 'report.sent': "Obrigado. Sua denúncia foi registrada.", + 'admin.tab_reports': "Denúncias", + 'admin.no_reports': "Nada aguarda decisão", + 'admin.col_reporters': "Denunciantes", + 'admin.btn_dismiss': "Descartar", + 'admin.report_block_confirm': "Bloquear este arquivo? Todos os nós que hospedam um grupo público deixarão de servi-lo ali.", + 'admin.reports_heading': "Denúncias de conteúdo", + 'admin.reports_hint': "Quem pode denunciar um arquivo em um grupo público, com que frequência e o que acontece quando membros suficientes o fizeram.", + 'admin.reports_min_account_age_hours': "Idade mínima da conta (horas)", + 'admin.reports_daily_per_account': "Denúncias por conta por dia", + 'admin.reports_review_threshold': "Membros antes da análise", + 'admin.reports_auto_block': "Bloquear sem análise (1 = sim, 0 = não)", + 'admin.reports_save': "Salvar configurações de denúncias", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 49ce189..0d9afab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1175,4 +1175,28 @@ export default { // In-page confirm/alert (ask.js) 'dialog.ok': '确定', 'dialog.cancel': '取消', + + // Content reports (report.js, admin-page.js) + 'report.action': "举报", + 'report.title': "举报此文件", + 'report.hint': "本中心的管理员会进行审核。", + 'report.reason_illegal': "违法内容", + 'report.reason_spam': "垃圾信息", + 'report.reason_copyright': "侵犯版权", + 'report.reason_other': "其他", + 'report.detail_placeholder': "详细说明(可选)", + 'report.send': "提交举报", + 'report.sent': "谢谢,您的举报已记录。", + 'admin.tab_reports': "举报", + 'admin.no_reports': "暂无待处理事项", + 'admin.col_reporters': "举报人数", + 'admin.btn_dismiss': "驳回", + 'admin.report_block_confirm': "要屏蔽此文件吗?所有托管公开群组的节点都将停止在那里提供它。", + 'admin.reports_heading': "内容举报", + 'admin.reports_hint': "谁可以举报公开群组中的文件、举报频率,以及足够多的成员举报后会发生什么。", + 'admin.reports_min_account_age_hours': "账户最短注册时长(小时)", + 'admin.reports_daily_per_account': "每个账户每天的举报数", + 'admin.reports_review_threshold': "进入审核所需人数", + 'admin.reports_auto_block': "无需审核直接屏蔽(1 = 是,0 = 否)", + 'admin.reports_save': "保存举报设置", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/report.js b/packages/meshbay-hub/src/meshbay_hub/static/report.js new file mode 100644 index 0000000..f433780 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/report.js @@ -0,0 +1,70 @@ +import { html, render, useEffect, useRef, useState } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; + +/** + * Ask why a file is being reported, drawn by the page like `ask.js`. + * + * Resolves `{ reason, detail }`, or null when cancelled. The reasons are the + * hub's own closed list (api/moderation.py `ReportRequest`), and the detail is + * bounded to what the hub keeps (256 characters). + */ + +const REASONS = ['illegal', 'spam', 'copyright', 'other']; + +function ReportDialog({ name, onDone }) { + const [reason, setReason] = useState('illegal'); + const [detail, setDetail] = useState(''); + const firstRef = useRef(null); + useEffect(() => { if (firstRef.current) firstRef.current.focus(); }, []); + + return html` + <div class="video-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-overlay')) onDone(null); + }}> + <form class="music-detail playlist-modal" role="dialog" aria-modal="true" + onKeyDown=${(e) => { if (e.key === 'Escape') { e.preventDefault(); onDone(null); } }} + onSubmit=${(e) => { + e.preventDefault(); + onDone({ reason, detail: detail.trim() || null }); + }}> + <div class="playlist-modal-body"> + <div class="ask-message"><strong>${t('report.title')}</strong></div> + <div class="ask-message file-name">${name}</div> + <p class="settings-hint">${t('report.hint')}</p> + <select ref=${firstRef} value=${reason} + onChange=${(e) => setReason(e.target.value)}> + ${REASONS.map((r) => html` + <option key=${r} value=${r}>${t('report.reason_' + r)}</option>`)} + </select> + <textarea maxlength="256" placeholder=${t('report.detail_placeholder')} + value=${detail} onInput=${(e) => setDetail(e.target.value)} /> + <div class="playlist-modal-actions"> + <button type="button" class="tb-btn" onClick=${() => onDone(null)}> + ${t('dialog.cancel')}</button> + <button type="submit" class="admin-btn">${t('report.send')}</button> + </div> + </div> + </form> + </div> + `; +} + +export function askReport(name) { + return new Promise((resolve) => { + const host = document.createElement('div'); + document.body.appendChild(host); + const previous = document.activeElement; + let settled = false; + const onDone = (value) => { + if (settled) return; + settled = true; + render(null, host); + host.remove(); + if (previous && previous.isConnected && typeof previous.focus === 'function') { + previous.focus(); + } + resolve(value); + }; + render(html`<${ReportDialog} name=${String(name)} onDone=${onDone} />`, host); + }); +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 8fcb9cb..7471e31 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -5351,7 +5351,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .playlist-modal { max-width: 420px; } .playlist-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; } -.playlist-modal-body input { +.playlist-modal-body input, +.playlist-modal-body select, +.playlist-modal-body textarea { width: 100%; padding: 9px 12px; border: 1px solid var(--border); @@ -5360,7 +5362,10 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } color: var(--text); font: inherit; } -.playlist-modal-body input:focus { +.playlist-modal-body textarea { resize: vertical; min-height: 4.5em; } +.playlist-modal-body input:focus, +.playlist-modal-body select:focus, +.playlist-modal-body textarea:focus { outline: none; border-color: var(--border-focus); } diff --git a/packages/meshbay-hub/tests/test_moderation.py b/packages/meshbay-hub/tests/test_moderation.py index b328d67..b599c84 100644 --- a/packages/meshbay-hub/tests/test_moderation.py +++ b/packages/meshbay-hub/tests/test_moderation.py @@ -48,77 +48,198 @@ async def admin_headers(client): return headers +async def _policy(client, admin, **values): + r = await client.patch("/v1/admin/settings", json={"reports": values}, headers=admin) + assert r.status_code == 200, r.text + return r.json()["reports"] + + +async def _public_group(client, db_session, owner, name="commons-mod"): + from datetime import UTC, datetime + + from meshbay_hub.db.models import Group + r = await client.post("/v1/groups", json={"name": name, "visibility": "public", + "join_policy": "open"}, headers=owner) + assert r.status_code == 201, r.text + gid = r.json()["group_id"] + (await db_session.get(Group, gid)).hosted_at = datetime.now(UTC) + await db_session.commit() + return gid + + +async def _members(client, gid, names): + out = [] + for n in names: + h = await _register_and_login(client, n) + assert (await client.post(f"/v1/groups/{gid}/join", headers=h)).status_code == 200 + out.append(h) + return out + + +async def _report(client, headers, gid, h=FAKE_HASH, **extra): + return await client.post("/v1/reports", headers=headers, + json={"content_hash": h, "group_id": gid, + "reason": "illegal", **extra}) + + +@pytest.fixture +async def setting(client, admin_headers): + """Reports allowed from a new account, so tests need not wait a day.""" + await _policy(client, admin_headers, min_account_age_hours=0) + return admin_headers + + @pytest.mark.asyncio async def test_report_requires_auth(client): - # No credentials at all — FastAPI rejects the missing header before the body. r = await client.post("/v1/reports", json={ - "content_hash": FAKE_HASH, "reason": "illegal"}) + "content_hash": FAKE_HASH, "group_id": "g", "reason": "illegal"}) assert r.status_code in (401, 422) - - # A bogus token is a clean 401. r = await client.post("/v1/reports", - json={"content_hash": FAKE_HASH, "reason": "illegal"}, + json={"content_hash": FAKE_HASH, "group_id": "g", + "reason": "illegal"}, headers={"Authorization": "Bearer not-a-real-token"}) assert r.status_code == 401 @pytest.mark.asyncio -async def test_report_content_logged(client, reporter): - r = await client.post("/v1/reports", - json={"content_hash": FAKE_HASH, "reason": "illegal"}, - headers=reporter) - assert r.status_code == 201 - data = r.json() - assert data["report_count"] == 1 - assert data["status"] == "logged" +async def test_a_node_token_cannot_report(client, db_session, setting): + owner = await _register_and_login(client, "owner_nodetok") + gid = await _public_group(client, db_session, owner, "nodetok-grp") + node = await _node_headers(client, "node_reporter") + assert (await _report(client, node, gid)).status_code == 403 @pytest.mark.asyncio -async def test_same_reporter_cannot_walk_the_threshold(client, reporter): +async def test_a_new_account_cannot_report_yet(client, db_session, admin_headers): + owner = await _register_and_login(client, "owner_young") + gid = await _public_group(client, db_session, owner, "young-grp") + [young] = await _members(client, gid, ["young_member"]) + r = await _report(client, young, gid) + assert r.status_code == 403 and "too new" in r.json()["detail"] + + +@pytest.mark.asyncio +async def test_only_a_member_of_that_public_group_may_report(client, db_session, setting): + owner = await _register_and_login(client, "owner_member") + gid = await _public_group(client, db_session, owner, "member-grp") + stranger = await _register_and_login(client, "stranger_one") + private = (await client.post("/v1/groups", json={"name": "priv-mod"}, + headers=owner)).json()["group_id"] + answers = {(await _report(client, stranger, gid)).json()["detail"], + (await _report(client, owner, private)).json()["detail"], + (await _report(client, stranger, "no-such-group")).json()["detail"]} + # One uniform refusal: it must not say which groups exist or who is in them. + assert len(answers) == 1 + assert (await _report(client, owner, gid)).status_code == 201 + + +@pytest.mark.asyncio +async def test_a_report_says_nothing_about_how_close_review_is(client, db_session, setting): + owner = await _register_and_login(client, "owner_quiet") + gid = await _public_group(client, db_session, owner, "quiet-grp") + r = await _report(client, owner, gid) + assert r.status_code == 201 and r.json() == {"status": "logged"} + + +@pytest.mark.asyncio +async def test_same_reporter_cannot_walk_the_threshold(client, db_session, setting): + await _policy(client, setting, review_threshold=1) + owner = await _register_and_login(client, "owner_walk") + gid = await _public_group(client, db_session, owner, "walk-grp") h = "b" * 64 - for _ in range(5): - r = await client.post("/v1/reports", - json={"content_hash": h, "reason": "spam"}, - headers=reporter) - assert r.json()["report_count"] == 1 + [m] = await _members(client, gid, ["walker_one"]) + await _report(client, m, gid, h) + for _ in range(4): + r = await _report(client, m, gid, h) assert r.json()["status"] == "already_reported" - assert not await _blocked(client, h) - @pytest.mark.asyncio -async def test_auto_block_on_distinct_reporters(client): +async def test_reaching_the_threshold_queues_for_an_administrator(client, db_session, setting): + owner = await _register_and_login(client, "owner_queue") + gid = await _public_group(client, db_session, owner, "queue-grp") h = "c" * 64 - for i in range(3): - headers = await _register_and_login(client, f"reporter_{i}") - r = await client.post("/v1/reports", - json={"content_hash": h, "reason": "illegal"}, - headers=headers) - assert r.json()["status"] == "auto_blocked" - assert r.json()["report_count"] == 3 + for m in await _members(client, gid, ["queue_r0", "queue_r1", "queue_r2"]): + assert (await _report(client, m, gid, h)).status_code == 201 + + assert not await _blocked(client, h), "nothing is blocked without a decision" + queue = (await client.get("/v1/admin/reports", headers=setting)).json()["reports"] + [item] = [q for q in queue if q["hash"] == h] + assert item["reporters"] == 3 and item["reasons"] == {"illegal": 3} + assert item["groups"] == [{"id": gid, "name": "queue-grp"}] + notes = (await client.get("/v1/notifications", headers=setting)).json() + assert any(n["kind"] == "content_review" for n in notes["notifications"]) + r = await client.post(f"/v1/admin/reports/{h}/block", headers=setting) + assert r.status_code == 200 assert await _blocked(client, h) + queue = (await client.get("/v1/admin/reports", headers=setting)).json()["reports"] + assert h not in [q["hash"] for q in queue] @pytest.mark.asyncio -async def test_reports_refused_when_public_groups_disabled(client, reporter, admin_headers): - await client.patch("/v1/admin/settings", - json={"allow_public_groups": False}, - headers=admin_headers) +async def test_a_dismissed_report_stays_dismissed(client, db_session, setting): + await _policy(client, setting, review_threshold=1) + owner = await _register_and_login(client, "owner_dismiss") + gid = await _public_group(client, db_session, owner, "dismiss-grp") + h = "d" * 64 + [a, b] = await _members(client, gid, ["dismiss_a", "dismiss_b"]) + await _report(client, a, gid, h) + assert (await client.post(f"/v1/admin/reports/{h}/dismiss", + headers=setting)).status_code == 200 + await _report(client, b, gid, h) + queue = (await client.get("/v1/admin/reports", headers=setting)).json()["reports"] + assert h not in [q["hash"] for q in queue] + assert not await _blocked(client, h) - r = await client.post("/v1/reports", - json={"content_hash": "d" * 64, "reason": "illegal"}, - headers=reporter) + +@pytest.mark.asyncio +async def test_automatic_blocking_is_the_instances_choice(client, db_session, setting): + await _policy(client, setting, review_threshold=2, auto_block=1) + owner = await _register_and_login(client, "owner_auto") + gid = await _public_group(client, db_session, owner, "auto-grp") + h = "e" * 64 + for m in await _members(client, gid, ["autoblock_r0", "autoblock_r1"]): + await _report(client, m, gid, h) + assert await _blocked(client, h) + + +@pytest.mark.asyncio +async def test_a_member_has_a_daily_allowance(client, db_session, setting): + await _policy(client, setting, daily_per_account=2) + owner = await _register_and_login(client, "owner_daily") + gid = await _public_group(client, db_session, owner, "daily-grp") + for i in range(2): + assert (await _report(client, owner, gid, f"{i:064x}")).status_code == 201 + assert (await _report(client, owner, gid, f"{9:064x}")).status_code == 429 + + +@pytest.mark.asyncio +async def test_a_report_is_shaped(client, db_session, setting): + owner = await _register_and_login(client, "owner_shape") + gid = await _public_group(client, db_session, owner, "shape-grp") + assert (await _report(client, owner, gid, "not-a-hash")).status_code == 422 + assert (await _report(client, owner, gid, reason="because")).status_code == 422 + assert (await _report(client, owner, gid, detail="x" * 257)).status_code == 422 + + +@pytest.mark.asyncio +async def test_only_an_administrator_decides(client, db_session, setting): + await _policy(client, setting, review_threshold=1) + owner = await _register_and_login(client, "owner_decide") + gid = await _public_group(client, db_session, owner, "decide-grp") + await _report(client, owner, gid, "f" * 64) + r = await client.post(f"/v1/admin/reports/{'f' * 64}/block", headers=owner) assert r.status_code == 403 @pytest.mark.asyncio -async def test_invalid_hash_rejected(client, reporter): - r = await client.post("/v1/reports", - json={"content_hash": "not-a-valid-blake3-hash", - "reason": "test"}, - headers=reporter) - assert r.status_code == 422 +async def test_reports_refused_when_public_groups_disabled(client, db_session, setting): + owner = await _register_and_login(client, "owner_off") + gid = await _public_group(client, db_session, owner, "off-grp") + await client.patch("/v1/admin/settings", json={"allow_public_groups": False}, + headers=setting) + assert (await _report(client, owner, gid)).status_code == 403 @pytest.mark.asyncio |