aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-28 22:06:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-28 22:06:07 +0200
commitf04b4e0c4350a5acc539a8f15bc9df4bfd10a537 (patch)
tree2635c9b2cf054fbbd24b9afef60fc5b4ba62075c /packages/meshbay-hub/src
parent07480eb3f8ad0bb4369ac8c41df7c4140b108d0e (diff)
downloadmeshbay-f04b4e0c4350a5acc539a8f15bc9df4bfd10a537.tar.gz
feat(hub): reports from public-group members, decided by an administrator
A report needs a person's account at least a day old, membership of the public group, and fits a daily allowance per account. Past the threshold a hash is queued and administrators are notified; blocking without review is an instance setting, off by default. Report menu item in public groups, Reports tab and settings in the admin panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py262
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f7a8b9c0d1e2_add_content_reviews.py35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js92
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/report.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css9
20 files changed, 745 insertions, 71 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);
}