aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py112
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/middleware.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py318
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/relay.py166
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py103
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/csam.py158
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py38
-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.py34
-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/app.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js95
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/lazy.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/portable-name.js51
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/report.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-media.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js79
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js8
54 files changed, 1374 insertions, 856 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/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 42f4101..2fd8b99 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -90,6 +90,17 @@ async def require_user_scope(
return current_user
+async def require_node_scope(
+ payload: dict = Depends(_decode_token),
+ current_user: User = Depends(get_current_user),
+) -> User:
+ """Only a node daemon's own token — for what a node fetches on its own behalf."""
+ if payload.get("scope") != "node":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
+ detail="Node token required")
+ return current_user
+
+
def user_is_admin(user: User) -> bool:
"""Admin by DB role or by the config allow-list. Use inside a handler that
already depends on `require_moderator` but has to draw the admin line for
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 3a11345..df2f336 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -20,16 +20,11 @@ from meshbay_hub.db.models import (
Group,
GroupMember,
IPLog,
- SwarmSource,
User,
)
router = APIRouter(prefix="/v1/groups", tags=["groups"])
-# Swarm endpoints live at /v1/swarm/*. They were previously declared on the groups
-# router with a full path, which mounted them at /v1/groups/v1/swarm/* (H7).
-swarm_router = APIRouter(prefix="/v1/swarm", tags=["swarm"])
-
@router.get("/mine")
async def my_groups(
@@ -211,113 +206,6 @@ async def list_public_groups(
return {"groups": groups, "total": len(groups)}
-# ── Swarm (content replication) ───────────────────────────────────────────────
-
-class SwarmRegisterRequest(BaseModel):
- content_hash: str # blake3 hex
- endpoint: str # "<scheme>:<port>" — a port on the caller, never a host
-
-
-# A transport and a port, and deliberately no host. The field used to be free
-# text documented as "ip:port", so a caller could name *someone else's*
-# address as a source; nothing dials a swarm source today, which is the only
-# reason that was not already a reflection primitive. A reader learns where a
-# node is from the node record, which is stamped with the address the announce
-# actually came from — so a host here would be a second, weaker, answer to a
-# question already settled elsewhere.
-_SWARM_ENDPOINT = re.compile(r"^(webrtc|quic):([0-9]{1,5})$")
-
-# One account, this many public hashes. Rows are keyed (hash, account) with no
-# cap, so a loop of invented hashes was unbounded storage growth on a hub
-# shared with everyone else. A public library far larger than this is a real
-# thing — but it is one a hub operator should be asked about, not something a
-# client establishes by writing rows.
-MAX_SWARM_HASHES_PER_ACCOUNT = 10_000
-
-
-@swarm_router.post("/register", status_code=201)
-@limiter.limit("120/minute")
-async def swarm_register(
- body: SwarmRegisterRequest,
- request: Request,
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
-):
- """
- Node registers itself as a source for a PUBLIC content hash.
-
- Finding H7: the node registered hashes for every group it hosted, private ones
- included, and this route was mounted at /v1/groups/v1/swarm/register — so the
- node's calls 404'd and the leak was masked by a routing bug rather than
- prevented. Nodes now filter by group visibility before calling, and the path is
- correct, so the filter has to be right.
-
- Availability: the endpoint is a port, not an address, and the number of
- hashes one account may claim is bounded. See the two constants above.
- """
- from meshbay_hub.csam import check_content_hash
- if check_content_hash(body.content_hash):
- raise HTTPException(status_code=451, detail="Content blocked")
-
- m = _SWARM_ENDPOINT.match(body.endpoint or "")
- if not m or not (0 < int(m.group(2)) < 65536):
- raise HTTPException(
- status_code=422,
- detail="endpoint must be '<webrtc|quic>:<port>' — a port on the "
- "registering node, not an address")
-
- from datetime import datetime
- existing = await db.get(SwarmSource, (body.content_hash, current_user.id))
- now = datetime.now(UTC)
- if existing:
- existing.endpoint = body.endpoint
- existing.last_seen = now
- else:
- held = (await db.execute(
- select(func.count()).select_from(SwarmSource)
- .where(SwarmSource.node_id == current_user.id))).scalar() or 0
- if held >= MAX_SWARM_HASHES_PER_ACCOUNT:
- raise HTTPException(
- status_code=429,
- detail="This account already claims the maximum number of "
- "public content hashes")
- db.add(SwarmSource(
- content_hash=body.content_hash,
- node_id=current_user.id,
- endpoint=body.endpoint,
- ))
- await db.commit()
- return {"status": "registered", "hash": body.content_hash}
-
-
-@swarm_router.get("/{content_hash}")
-async def swarm_sources(
- content_hash: str,
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
-):
- """
- Return nodes that can serve a content hash.
-
- Authenticated (H7): an open endpoint lets anyone probe whether a given file
- exists anywhere in the network and which node holds it.
- """
- from datetime import datetime, timedelta
- cutoff = datetime.now(UTC) - timedelta(minutes=30)
- result = await db.execute(
- select(SwarmSource)
- .where(
- SwarmSource.content_hash == content_hash,
- SwarmSource.last_seen > cutoff,
- )
- )
- sources = result.scalars().all()
- return {
- "hash": content_hash,
- "sources": [{"node_id": s.node_id, "endpoint": s.endpoint} for s in sources],
- }
-
-
@router.get("/{group_id}/members")
async def group_members(
group_id: str,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/middleware.py b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
index bed7b54..3a2a5c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
@@ -7,7 +7,11 @@ to mitigate credential stuffing and registration floods.
"""
from slowapi import Limiter
-from slowapi.util import get_remote_address
-# Rate limiter instance — mounted on the FastAPI app in app.py
-limiter = Limiter(key_func=get_remote_address)
+from meshbay_hub.api.netutil import client_ip
+
+# Rate limiter instance — mounted on the FastAPI app in app.py.
+# Keyed on client_ip, not slowapi's get_remote_address: behind Caddy every
+# request's peer is loopback, so the peer address put the whole internet in one
+# bucket — ten node sign-ins a minute, shared by every node there is.
+limiter = Limiter(key_func=client_ip)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
index 35c688c..038f310 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -1,59 +1,88 @@
"""
-MeshBay Hub — moderation endpoints.
+MeshBay Hub — moderation endpoints (docs/MESHBAY_DESIGN.md §7.5).
-Reporting flow:
- POST /v1/reports — report a content hash (sign-in required)
+Reporting:
+ POST /v1/reports — a member of a public group reports a file they saw there.
- Thresholds (counted as DISTINCT reporting accounts, not raw rows):
- < AUTO_BLOCK_THRESHOLD distinct reporters → logged
- >= AUTO_BLOCK_THRESHOLD distinct reporters → hash added to the blocklist
+ Who may: a person's account (never a node's token), old enough
+ (`reports.min_account_age_hours`), an active member of that public group,
+ within a daily allowance (`reports.daily_per_account`) as well as the per-address
+ rate limit. One report per account per hash.
+
+ What it leads to: once `reports.review_threshold` distinct accounts have
+ reported a hash, it is queued for an administrator (`content_reviews`), who is
+ notified and blocks or dismisses it. With `reports.auto_block` on, it is blocked
+ at once instead — the instance's choice, off by default, because a handful of
+ accounts made for the purpose would then be enough to take a file down.
The flow only runs while the hub brokers public content: with public groups
- switched off instance-wide there is nothing here to serve a reported hash from,
- so it is refused rather than left open as an unauthenticated write surface.
+ switched off there is nothing here to report.
Admin endpoints:
- GET /v1/admin/blocklist — list blocked hashes
- POST /v1/admin/blocklist — manually add a hash
- DELETE /v1/admin/blocklist/{hash} — remove a hash
+ GET /v1/admin/reports — hashes waiting for a decision
+ POST /v1/admin/reports/{hash}/block — block it, and tell the nodes
+ POST /v1/admin/reports/{hash}/dismiss — close it without blocking
+ GET /v1/admin/blocklist — list blocked hashes
+ POST /v1/admin/blocklist — manually add a hash
+ DELETE /v1/admin/blocklist/{hash} — remove a hash
Node integration:
- GET /v1/blocklist/check?hash=<blake3> — check if a hash is blocked
- GET /v1/blocklist — full blocklist (for node sync)
+ GET /v1/blocklist?after=<hash> — the list, paged, for a node's own token
+ WebSocket `blocklist_update` — additions and removals, pushed to nodes
+ hosting a public group
"""
import logging
+from collections import Counter
+from datetime import UTC, datetime, timedelta
+from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import hub_settings
-from meshbay_hub.api.deps import get_current_user, require_admin
+from meshbay_hub.api.deps import (
+ require_admin,
+ require_moderator,
+ require_node_scope,
+ require_user_scope,
+ user_is_admin,
+)
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
+from meshbay_hub.api.revocation import broadcast_blocklist_update
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import ContentBlocklist, ContentReport, User
+from meshbay_hub.db.models import (
+ ContentBlocklist,
+ ContentReport,
+ ContentReview,
+ Group,
+ GroupMember,
+ User,
+)
log = logging.getLogger(__name__)
router = APIRouter(tags=["moderation"])
-# Distinct reporting accounts before a hash is auto-blocked. Kept low for a
-# responsive community signal, but note it is only as strong as account
-# creation: while a bot can register freely (see the reCAPTCHA gap), the real
-# control is the admin reviewing `GET /v1/admin/blocklist` and the audit log.
-AUTO_BLOCK_THRESHOLD = 3
+# One answer for every reason a report is not accepted from this account for this
+# group, so the endpoint does not tell anyone which groups exist or who is in them.
+_NOT_YOURS = "You can report a file only in a public group you are a member of."
+
+
+def _is_hash(value: str) -> bool:
+ return len(value) == 64 and all(c in "0123456789abcdef" for c in value)
# ── Models ────────────────────────────────────────────────────────────────────
class ReportRequest(BaseModel):
- content_hash: str # blake3 hex (64 chars)
- group_id: str | None = None
- reason: str = "illegal"
- detail: str | None = None
+ content_hash: str = Field(max_length=64) # blake3 hex (64 chars)
+ group_id: str = Field(max_length=36)
+ reason: Literal["illegal", "spam", "copyright", "other"] = "illegal"
+ detail: str | None = Field(default=None, max_length=256)
class BlocklistAddRequest(BaseModel):
@@ -61,124 +90,150 @@ class BlocklistAddRequest(BaseModel):
reason: str
-# ── Public endpoints ──────────────────────────────────────────────────────────
+# ── Reporting ─────────────────────────────────────────────────────────────────
@router.post("/v1/reports", status_code=201)
@limiter.limit("10/hour")
async def report_content(
body: ReportRequest,
request: Request,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
"""
- Report a public content hash for moderation.
+ Report a file of a public group, as a member of that group.
- Sign-in is required. It used to be anonymous, which made it a censorship
- primitive: two unauthenticated POSTs naming any blake3 id auto-added it to
- the blocklist that nodes enforce, network-wide, with manual admin removal the
- only undo. The threshold now counts *distinct reporting accounts*, one vote
- per account per hash.
-
- Refused entirely when the hub has public groups switched off: nothing here
- brokers public content then, nothing syncs the blocklist, and an open write
- endpoint would only be abuse surface.
+ Every bound here answers what a report costs someone else: a file taken out
+ of a group everyone else uses, and an administrator's time. So a report takes
+ a person's account (a node's token is refused), one that has existed for a
+ while, membership of the public group the file was seen in, and a daily
+ allowance per account besides the rate limit per address — an address is one
+ of thousands a subscriber holds. It never blocks anything by itself unless the
+ instance chose automatic blocking: past the threshold, an administrator
+ decides.
"""
if not await hub_settings.public_groups_allowed(db):
raise HTTPException(
status_code=403,
detail="This hub does not broker public content, so there is nothing to report here.")
-
- if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash):
+ if not _is_hash(body.content_hash):
raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
+ limits = await hub_settings.report_limits(db)
+ now = datetime.now(UTC)
+
+ created = current_user.created_at
+ if created is not None and created.tzinfo is None:
+ created = created.replace(tzinfo=UTC)
+ if created is not None and \
+ now - created < timedelta(hours=limits["min_account_age_hours"]):
+ raise HTTPException(status_code=403,
+ detail="This account is too new to report content yet.")
+
+ group = await db.get(Group, body.group_id)
+ member = await db.scalar(select(GroupMember.user_id).where(
+ GroupMember.group_id == body.group_id,
+ GroupMember.user_id == current_user.id))
+ if group is None or group.visibility != "public" or group.status != "active" \
+ or member is None:
+ raise HTTPException(status_code=403, detail=_NOT_YOURS)
+
+ today = await db.scalar(select(func.count(ContentReport.id)).where(
+ ContentReport.reporter_id == current_user.id,
+ ContentReport.reported_at > now - timedelta(days=1))) or 0
+ if today >= limits["daily_per_account"]:
+ raise HTTPException(status_code=429,
+ detail="You have reached today's number of reports.")
+
# One vote per account per hash — a single reporter must not be able to walk
# the threshold up on their own by posting repeatedly.
already = await db.scalar(
select(ContentReport.id).where(
ContentReport.content_hash == body.content_hash,
ContentReport.reporter_id == current_user.id))
+ if already:
+ return {"status": "already_reported"}
- if not already:
- db.add(ContentReport(
- content_hash=body.content_hash,
- reporter_id=current_user.id,
- group_id=body.group_id,
- reason=body.reason,
- detail=body.detail,
- ip_address=client_ip(request),
- ))
- await db.flush()
+ db.add(ContentReport(
+ content_hash=body.content_hash,
+ reporter_id=current_user.id,
+ group_id=body.group_id,
+ reason=body.reason,
+ detail=body.detail,
+ ip_address=client_ip(request),
+ ))
+ await db.flush()
distinct_reporters = await db.scalar(
select(func.count(func.distinct(ContentReport.reporter_id)))
.where(ContentReport.content_hash == body.content_hash)) or 0
- action = "already_reported" if already else "logged"
- if distinct_reporters >= AUTO_BLOCK_THRESHOLD:
- existing = await db.get(ContentBlocklist, body.content_hash)
- if not existing:
- db.add(ContentBlocklist(
- content_hash=body.content_hash,
- reason=f"auto:{body.reason}",
- added_by="auto",
- ))
- action = "auto_blocked"
+ blocked_now = False
+ if distinct_reporters >= limits["review_threshold"] \
+ and await db.get(ContentBlocklist, body.content_hash) is None:
+ review = await db.get(ContentReview, body.content_hash)
+ if review is not None and review.status == "dismissed":
+ pass # an administrator's decision stands; more reports do not reopen it
+ elif limits["auto_block"]:
+ db.add(ContentBlocklist(content_hash=body.content_hash,
+ reason=f"auto:{body.reason}", added_by="auto"))
+ if review is None:
+ db.add(ContentReview(content_hash=body.content_hash, status="blocked",
+ decided_at=now, decided_by="auto"))
+ else:
+ review.status, review.decided_at, review.decided_by = "blocked", now, "auto"
+ blocked_now = True
log.warning("Content auto-blocked after %d distinct reporters: %s",
distinct_reporters, body.content_hash[:16])
-
+ elif review is None:
+ db.add(ContentReview(content_hash=body.content_hash, status="pending"))
+ await _notify_admins(db, body.content_hash)
+ log.warning("Content queued for review after %d distinct reporters: %s",
+ distinct_reporters, body.content_hash[:16])
await db.commit()
- return {
- "status": action,
- "content_hash": body.content_hash,
- "report_count": distinct_reporters,
- "threshold": AUTO_BLOCK_THRESHOLD,
- }
-
+ if blocked_now:
+ await broadcast_blocklist_update(db, add=[body.content_hash])
+ # The same answer whatever happened next: a reporter is not told how close a
+ # file is to review, which is a count to aim at.
+ return {"status": "logged"}
-@router.get("/v1/blocklist/check")
-@limiter.limit("120/minute")
-async def check_blocklist(
- hash: str,
- request: Request,
- db: AsyncSession = Depends(get_db),
-):
- """Check if a single hash is blocked. Used by nodes before serving public content.
- Unauthenticated, because a node consults it before serving public content
- and does so on its own behalf. That makes the shape check worth having:
- without it any string of any length became a primary-key lookup.
- """
- if len(hash) != 64 or not all(c in "0123456789abcdef" for c in hash):
- raise HTTPException(status_code=422, detail="hash must be 64 hex chars (blake3)")
- blocked = await db.get(ContentBlocklist, hash)
- return {
- "blocked": blocked is not None,
- "hash": hash,
- "reason": blocked.reason if blocked else None,
- }
+async def _notify_admins(db: AsyncSession, content_hash: str) -> None:
+ from meshbay_hub.api.notifications import create_notification
+ admins = [u for u in (await db.execute(select(User).where(
+ User.status == "active"))).scalars().all() if user_is_admin(u)]
+ for admin in admins:
+ await create_notification(
+ db, admin.id, "content_review",
+ "Reported content is waiting for a decision",
+ detail=content_hash[:16], link="#/admin", aggregate=False)
@router.get("/v1/blocklist")
async def get_blocklist(
+ current_node: User = Depends(require_node_scope),
db: AsyncSession = Depends(get_db),
- # Bounded, like every other list. This one takes no authentication — a
- # node syncs it at startup — and had no ceiling at all, so any stranger
- # could ask for the table in one query, repeatedly. 10 000 is what a node
- # asks for, so it is the default and also the most anyone may have.
+ after: str = Query(default="", max_length=64),
limit: int = Query(default=10000, ge=1, le=10000),
):
"""
- Return the full blocklist. Nodes sync this on startup.
- Returns hashes only (not reasons) to minimize data exposure.
+ The content blocklist, a page at a time, for a node hosting a public group.
+
+ Hashes only, never the reasons. Ordered by hash so `after` (the last hash of
+ the previous page) is a stable cursor: a list longer than one page used to
+ be cut at 10 000 with no way to ask for the rest, and the node applying it
+ silently served everything past the cut. A node's own token, because this
+ is what a node fetches on its own behalf and nothing else asks for it.
"""
result = await db.execute(
select(ContentBlocklist.content_hash)
- .order_by(ContentBlocklist.added_at.desc())
+ .where(ContentBlocklist.content_hash > after)
+ .order_by(ContentBlocklist.content_hash)
.limit(limit)
)
hashes = [row[0] for row in result.fetchall()]
- return {"count": len(hashes), "hashes": hashes}
+ return {"hashes": hashes,
+ "next": hashes[-1] if len(hashes) == limit else None}
# ── Admin endpoints ───────────────────────────────────────────────────────────
@@ -214,16 +269,19 @@ async def admin_add_blocklist(
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
+ if not _is_hash(body.content_hash):
+ raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
existing = await db.get(ContentBlocklist, body.content_hash)
if existing:
raise HTTPException(status_code=409, detail="Hash already blocked")
db.add(ContentBlocklist(
content_hash=body.content_hash,
- reason=body.reason,
+ reason=body.reason[:64],
added_by=current_user.username,
))
await db.commit()
+ await broadcast_blocklist_update(db, add=[body.content_hash])
return {"status": "blocked", "hash": body.content_hash}
@@ -238,5 +296,73 @@ async def admin_remove_blocklist(
raise HTTPException(status_code=404, detail="Hash not in blocklist")
await db.delete(entry)
await db.commit()
+ await broadcast_blocklist_update(db, remove=[content_hash])
return {"status": "unblocked", "hash": content_hash}
+
+# ── Review queue ──────────────────────────────────────────────────────────────
+
+@router.get("/v1/admin/reports")
+async def admin_list_reports(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+ limit: int = Query(default=100, ge=1, le=500),
+):
+ """Hashes waiting for a decision, oldest first, with what was said about them."""
+ reviews = (await db.execute(
+ select(ContentReview).where(ContentReview.status == "pending")
+ .order_by(ContentReview.opened_at).limit(limit))).scalars().all()
+ out = []
+ for r in reviews:
+ reports = (await db.execute(select(ContentReport).where(
+ ContentReport.content_hash == r.content_hash))).scalars().all()
+ group_ids = sorted({x.group_id for x in reports if x.group_id})
+ names = dict((await db.execute(select(Group.id, Group.name).where(
+ Group.id.in_(group_ids)))).all()) if group_ids else {}
+ out.append({
+ "hash": r.content_hash,
+ "opened_at": r.opened_at.isoformat(),
+ "reporters": len({x.reporter_id for x in reports}),
+ "reasons": dict(Counter(x.reason for x in reports)),
+ "details": [x.detail for x in reports if x.detail][:10],
+ "groups": [{"id": g, "name": names.get(g, "")} for g in group_ids],
+ })
+ return {"reports": out}
+
+
+async def _decide(db: AsyncSession, content_hash: str, status: str, by: str) -> ContentReview:
+ review = await db.get(ContentReview, content_hash)
+ if review is None or review.status != "pending":
+ raise HTTPException(status_code=404, detail="Nothing waiting for this hash")
+ review.status, review.decided_at, review.decided_by = status, datetime.now(UTC), by
+ return review
+
+
+@router.post("/v1/admin/reports/{content_hash}/block")
+async def admin_block_reported(
+ content_hash: str,
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ await _decide(db, content_hash, "blocked", current_user.username)
+ reasons = Counter((await db.execute(select(ContentReport.reason).where(
+ ContentReport.content_hash == content_hash))).scalars().all())
+ if await db.get(ContentBlocklist, content_hash) is None:
+ db.add(ContentBlocklist(
+ content_hash=content_hash,
+ reason=f"reported:{reasons.most_common(1)[0][0] if reasons else 'other'}",
+ added_by=current_user.username))
+ await db.commit()
+ await broadcast_blocklist_update(db, add=[content_hash])
+ return {"status": "blocked", "hash": content_hash}
+
+
+@router.post("/v1/admin/reports/{content_hash}/dismiss")
+async def admin_dismiss_reported(
+ content_hash: str,
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ await _decide(db, content_hash, "dismissed", current_user.username)
+ await db.commit()
+ return {"status": "dismissed", "hash": content_hash}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 6205180..403f450 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -21,10 +21,18 @@ from meshbay_hub.db.models import GroupMember, IPLog, Node, User
router = APIRouter(prefix="/v1/nodes", tags=["nodes"])
+class MnpTokenRequest(BaseModel):
+ # The base64 Ed25519 key of the node this token is for. The token is bound
+ # to it (E10), so it cannot be replayed to another node. The client knows it
+ # from `/v1/groups/{id}/nodes` before it connects.
+ node_pk: str = ""
+
+
@router.post("/mnp-token")
@limiter.limit("60/minute")
async def mnp_token(
request: Request,
+ body: MnpTokenRequest | None = None,
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
@@ -32,16 +40,22 @@ async def mnp_token(
Asked for with the member's own session token (require_user_scope, so a node
daemon token cannot mint one). The result carries the member's current group
- membership and `aud=MNP_AUD`, so it authorises the member to a node and is
- refused by the hub API. Short-lived on purpose; the client refetches it for a
- new connection or a reconnect, and it is checked only at the handshake, so a
- film already playing is never interrupted by its expiry.
+ membership, `aud=MNP_AUD` and the target node's key, so it authorises the
+ member to **that** node only and is refused by the hub API and by any other
+ node. Short-lived on purpose; the client refetches it for a new connection or
+ a reconnect, and it is checked only at the handshake, so a film already
+ playing is never interrupted by its expiry.
+
+ The hub does not verify the node key it is handed — binding the token to it
+ only *restricts* the token to whatever node holds that key, which is the one
+ the client is connecting to; a wrong key yields a token no node will accept.
"""
rows = await db.execute(
select(GroupMember.group_id).where(GroupMember.user_id == current_user.id))
group_ids = [gid for (gid,) in rows.all()]
return {
- "mnp_token": issue_mnp_token(current_user.id, groups=group_ids),
+ "mnp_token": issue_mnp_token(current_user.id, groups=group_ids,
+ node_pk=(body.node_pk if body else "")),
"expires_in": 900,
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
deleted file mode 100644
index 7bb3f66..0000000
--- a/packages/meshbay-hub/src/meshbay_hub/api/relay.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""
-MeshBay Hub — Mesh Relay registration protocol (5.3).
-
-Community-operated TURN relays register with hubs.
-Nodes query the hub for available relays when UDP hole punching fails.
-
-Relay registration:
- POST /v1/relays/register — relay announces itself (signed JWT)
- GET /v1/relays — list active relays (for nodes)
-
-Relay authentication: relay generates an Ed25519 keypair at install time. An
-admin approves the public key, and every register call carries an Ed25519
-signature over "meshbay:relay_register:<relay_id>:<endpoint>:<timestamp>" —
-the same proof-of-possession shape as /v1/nodes/announce.
-
-Relay is responsible for E2E encrypted QUIC traffic only (it cannot
-read the application-layer content, only forward UDP packets).
-"""
-
-import base64
-import logging
-import time
-
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from meshbay_hub.api.deps import require_admin
-from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import User
-
-log = logging.getLogger(__name__)
-
-# **Closed, the same way and for a similar reason as federation.** Nothing in the
-# tree calls these routes — no node asks for a relay, no client offers one — and
-# §11.1 measured two ISPs with no TURN relay needed. Two of the three take no
-# account and answer anyone who can reach the hub, so a registry nothing uses
-# was an unauthenticated surface kept for its own sake. A constant, not a
-# setting: re-opening it means building the node side first, then flipping this.
-RELAYS_ENABLED = False
-
-
-def _relays_open() -> None:
- """Refuse every route on this router while the registry is closed.
-
- On the router rather than in each handler, so a route added later is closed
- before anybody remembers to write the check (C6).
- """
- if not RELAYS_ENABLED:
- raise HTTPException(status_code=503,
- detail="The relay registry is not enabled on this hub")
-
-
-router = APIRouter(prefix="/v1/relays", tags=["relay"],
- dependencies=[Depends(_relays_open)])
-
-# In-memory relay registry (production: DB table)
-_relays: dict[str, dict] = {} # relay_id → {endpoint, pk, last_seen, capacity}
-
-
-# ── Models ────────────────────────────────────────────────────────────────────
-
-class RelayRegisterRequest(BaseModel):
- """Relay self-registers, proving possession of its approved key."""
- relay_id: str
- endpoint: str # "ip:port" (UDP)
- pk_relay: str # base64 Ed25519 public key
- capacity: int = 100 # max concurrent connections
- timestamp: int | None = None # unix seconds
- signature: str | None = None # base64 Ed25519 over the register message
-
-
-class RelayAdminApproveRequest(BaseModel):
- relay_id: str
- pk_relay: str # admin approves by registering the relay's public key
-
-
-# ── Relay endpoints ───────────────────────────────────────────────────────────
-
-REGISTER_TIMESTAMP_WINDOW = 300 # seconds either side, as /v1/nodes/announce
-
-
-@router.post("/register", status_code=201)
-async def relay_register(
- body: RelayRegisterRequest,
- db: AsyncSession = Depends(get_db),
-):
- """
- Relay announces itself. Must be pre-approved by a hub admin, and must prove
- it holds the private key that approval registered.
-
- This endpoint has no `Depends` on an account on purpose — a relay is not a
- user — but it had no proof of anything either: it compared `pk_relay`
- against the approved value, which is a **public** key, so anyone who could
- read it could rewrite where the hub tells nodes to send relayed traffic.
- The module docstring said "signs keepalive JWTs" and nothing verified a
- signature; `jwt` was imported and never used. A key is not a password, and
- the fix is the proof-of-possession pattern already used by
- /v1/nodes/announce and /v1/nodes/auth.
- """
- approved = _relays.get(body.relay_id)
- if not approved or approved.get("pk") != body.pk_relay:
- raise HTTPException(status_code=403,
- detail="Relay not approved — ask the hub admin to "
- "run POST /v1/relays/approve")
-
- if body.timestamp is None or not body.signature:
- raise HTTPException(
- status_code=400,
- detail="register requires timestamp and signature (proof of possession)")
- if abs(int(time.time()) - body.timestamp) > REGISTER_TIMESTAMP_WINDOW:
- raise HTTPException(status_code=401, detail="Timestamp too old or too far ahead")
-
- message = (f"meshbay:relay_register:{body.relay_id}:"
- f"{body.endpoint}:{body.timestamp}").encode()
- try:
- pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(body.pk_relay))
- pk.verify(base64.b64decode(body.signature), message)
- except Exception:
- log.warning("Relay %s failed proof of possession", body.relay_id[:8])
- raise HTTPException(status_code=401, detail="Invalid relay key proof of possession")
-
- _relays[body.relay_id].update({
- "endpoint": body.endpoint,
- "capacity": body.capacity,
- "last_seen": int(time.time()),
- "active": True,
- })
- log.info("Relay registered: %s at %s", body.relay_id[:8], body.endpoint)
- return {"status": "registered", "relay_id": body.relay_id}
-
-
-@router.get("")
-async def list_relays():
- """
- List active Mesh Relays. Called by nodes when UDP hole punching fails.
- Returns only active relays (seen in the last 5 minutes).
- """
- cutoff = int(time.time()) - 300
- active = [
- {
- "relay_id": rid,
- "endpoint": r["endpoint"],
- "capacity": r["capacity"],
- }
- for rid, r in _relays.items()
- if r.get("active") and r.get("last_seen", 0) > cutoff
- ]
- return {"relays": active, "count": len(active)}
-
-
-@router.post("/approve", status_code=201)
-async def admin_approve_relay(
- body: RelayAdminApproveRequest,
- current_user: User = Depends(require_admin),
-):
- """Admin: pre-approve a relay by registering its public key."""
- _relays[body.relay_id] = {
- "pk": body.pk_relay,
- "approved_by": current_user.username,
- "approved_at": int(time.time()),
- "active": False, # becomes True after first register call
- }
- log.info("Relay approved by %s: %s", current_user.username, body.relay_id[:8])
- return {"status": "approved", "relay_id": body.relay_id}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index f8cae8a..fe9edf3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -165,6 +165,35 @@ async def broadcast_revocation(token: str) -> int:
return sent
+async def broadcast_blocklist_update(db: AsyncSession, *, add: list[str] = (),
+ remove: list[str] = ()) -> int:
+ """
+ Tell every connected node that hosts a public group what changed on the
+ content blocklist. Returns how many were told.
+
+ Only those nodes: the list names public content, and a node hosting only
+ private groups has nothing to apply it to. A node that is offline now syncs
+ the whole list when it next connects (`GET /v1/blocklist`), so a missed push
+ is only late, never lost.
+ """
+ if not add and not remove:
+ return 0
+ public = set((await db.execute(
+ select(Group.id).where(Group.visibility == "public"))).scalars().all())
+ payload = json.dumps({"type": "blocklist_update",
+ "add": list(add), "remove": list(remove)})
+ sent = 0
+ for node_id, ws in list(_connected_nodes.items()):
+ if not public.intersection(_node_groups.get(node_id, [])):
+ continue
+ try:
+ await ws.send_text(payload)
+ sent += 1
+ except Exception:
+ _connected_nodes.pop(node_id, None)
+ return sent
+
+
def _sign_revocation(target: str, target_id: str, reason: str) -> str:
"""Issue a signed revocation token (JWT EdDSA)."""
from meshbay_hub.auth import _hub_id, _hub_sk_pem
@@ -441,6 +470,7 @@ async def notify_incoming(
body: IncomingRequest,
request: Request,
current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
):
"""
Signal a node that a client wants to connect (NAT punch coordination).
@@ -450,8 +480,18 @@ async def notify_incoming(
arbitrary node emit UDP packets to an address of their choosing — a small
reflection primitive using someone else's machine. The probe target must now be
the caller's own source address.
+
+ Like the offer relay, the caller must share an active group with the node —
+ checked **before** anything reveals whether the node is connected, so this is
+ not a liveness oracle a stranger can poll, and a stranger cannot make a node
+ punch on their behalf.
"""
from meshbay_hub.api.netutil import client_ip
+ from meshbay_hub.api.signaling import require_shared_active_group
+
+ # First, and before anything reveals whether the node is connected: a
+ # stranger cannot poll this for a node's liveness, nor make it punch.
+ await require_shared_active_group(db, node_id, current_user.id)
caller_ip = client_ip(request)
if body.peer_ip != caller_ip:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index 60d5e20..6c9699b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -102,6 +102,51 @@ def _take_offer(user_id: str, node_id: str, now: float) -> float | None:
return None
+async def require_shared_active_group(db: AsyncSession, node_id: str, user_id: str) -> None:
+ """The caller must share an **active** group with this node, or the node must
+ host an open group while public groups are on (that path *is* what a public
+ group means, so it follows the instance switch). Raises 403 with a uniform
+ message otherwise — the same answer whether the node is a member's or a
+ stranger's, and whether it is connected or not, so it is not a liveness
+ oracle for a non-member.
+
+ Membership is read from the connected-node registry, so a node hosting no
+ group shares one with nobody (AV24, AV1): the empty claim is "no groups", not
+ "all of its owner's". Both the WebRTC offer relay and the NAT-punch signal
+ call this, so they gate the same way (H6).
+ """
+ from meshbay_hub.api.revocation import _node_groups
+ node_group_ids = set(_node_groups.get(node_id, []))
+ if not node_group_ids:
+ raise HTTPException(status_code=403,
+ detail="Not a member of any group on this node")
+ shared = [gid for (gid,) in (await db.execute(
+ select(GroupMember.group_id).where(
+ GroupMember.user_id == user_id,
+ GroupMember.group_id.in_(node_group_ids),
+ ))).all()]
+ if not shared:
+ has_open = None
+ if await hub_settings.public_groups_allowed(db):
+ has_open = (await db.execute(
+ select(Group.id).where(
+ Group.id.in_(node_group_ids),
+ Group.join_policy == "open",
+ Group.status == "active",
+ ))).first()
+ if not has_open:
+ raise HTTPException(status_code=403,
+ detail="Not a member of any group on this node")
+ return
+ statuses = set((await db.execute(
+ select(Group.status).where(Group.id.in_(shared)))).scalars().all())
+ if "active" not in statuses:
+ # Report the strongest state present — "revoked" is the signed,
+ # node-enforced one; "suspended" is the reversible hub flag.
+ state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
+ raise HTTPException(status_code=403, detail=f"Group is {state}")
+
+
@router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse)
# Per address and per node, and only a coarse guard in front of authentication:
# the account's budget above is the limit that means something. 600 because an
@@ -126,7 +171,7 @@ async def webrtc_offer(
Finding H4: it also ignored group status, so "suspend a group" did not stop new
connections from being brokered to nodes hosting it.
"""
- from meshbay_hub.api.revocation import _connected_nodes, _node_groups
+ from meshbay_hub.api.revocation import _connected_nodes
if len(body.sdp) > MAX_SDP_BYTES:
raise HTTPException(status_code=413, detail="SDP too large")
@@ -143,58 +188,10 @@ async def webrtc_offer(
if not ws:
raise HTTPException(status_code=404, detail="Node not connected")
- # The caller must share at least one active group with the target node,
- # OR the node must host at least one open-join group (public groups admit
- # anyone — the node's MNP handshake handles authorization).
- #
- # That second path is exactly what "public groups" means, so it is gated by
- # the instance switch: with public groups off, a non-member is not brokered a
- # connection to a node just because it happens to host an open group. Members
- # of that group are unaffected — they match `shared` below.
- node_group_ids = set(_node_groups.get(node_id, []))
- # A node registered for no group shares no group with anybody, which is this
- # check's own answer — and `if node_group_ids:` used to skip the whole thing,
- # membership, group status and the public-group gate together. Since AV1 made
- # an empty claim mean "no groups" rather than "all of my owner's", that is
- # the *normal* registration of a node hosting nothing: exactly the
- # unconfigured node left running that took a group down on 2026-09-11. So the
- # machine least able to defend itself was the one any authenticated account
- # could make allocate a peer connection and gather ICE, which is H6 restored
- # in the one case AV1 made common.
- #
- # Nothing legitimate is lost by refusing here: a browser cannot complete a
- # handshake with such a node anyway — `group_id` is mandatory (M1) and a node
- # holding no group key refuses outright (NS8) — so this only declines work
- # the node would decline one step later, at its own expense.
- if not node_group_ids:
- raise HTTPException(status_code=403,
- detail="Not a member of any group on this node")
-
- result = await db.execute(
- select(GroupMember.group_id).where(
- GroupMember.user_id == current_user.id,
- GroupMember.group_id.in_(node_group_ids),
- ))
- shared = [gid for (gid,) in result.all()]
- if not shared:
- has_open = None
- if await hub_settings.public_groups_allowed(db):
- has_open = (await db.execute(
- select(Group.id).where(
- Group.id.in_(node_group_ids),
- Group.join_policy == "open",
- Group.status == "active",
- ))).first()
- if not has_open:
- raise HTTPException(status_code=403, detail="Not a member of any group on this node")
- else:
- statuses = set((await db.execute(
- select(Group.status).where(Group.id.in_(shared)))).scalars().all())
- if "active" not in statuses:
- # Report the strongest state present — "revoked" is the signed,
- # node-enforced one; "suspended" is the reversible hub flag.
- state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
- raise HTTPException(status_code=403, detail=f"Group is {state}")
+ # The caller must share an active group with the node (or the node must host
+ # an open group when public groups are on). One implementation, shared with
+ # the NAT-punch signal (`notify_incoming`), so both gate the same way.
+ await require_shared_active_group(db, node_id, current_user.id)
# Both refusals say when to come back, and transport.js does: a 429 here is
# the hub being busy, never the node being down.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 7046c2f..3e996a7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -41,7 +41,6 @@ from meshbay_hub.db.models import (
Node,
Notification,
RefreshToken,
- SwarmSource,
User,
UserDevice,
UserPreference,
@@ -1379,14 +1378,13 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
the person it is about.
Gone: credentials, email, node key, group memberships, notifications, refresh
- tokens, node registrations, device keys, public-swarm sources. The username
+ tokens, node registrations, device keys. The username
is released.
Device keys go even though the desktop client keeps its private half: left
behind, the key still belongs to this tombstone, so an account created later
from the same installation is refused that device ("belongs to another
- account"). Swarm sources are keyed by the *user* id and carry the node's
- ip:port.
+ account").
Kept: the row itself, emptied, and the IP log that points at it. Those logs
exist for one year to answer legal requests, and a log that cannot say whose
@@ -1419,7 +1417,6 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id))
await db.execute(delete(Node).where(Node.user_id == user.id))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
- await db.execute(delete(SwarmSource).where(SwarmSource.node_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
# Links this account issued for a group it no longer owns; the ones for its
# own groups went with them above. A used link keeps pointing at the
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 7ccf598..16a9d4a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -21,7 +21,6 @@ from meshbay_hub.api.admin import router as admin_router
from meshbay_hub.api.deps import set_admin_usernames
from meshbay_hub.api.federation import router as federation_router
from meshbay_hub.api.groups import router as groups_router
-from meshbay_hub.api.groups import swarm_router
from meshbay_hub.api.health import router as health_router
from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.hub import set_config as hub_set_config
@@ -31,7 +30,6 @@ from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.nodes import router as nodes_router
from meshbay_hub.api.notifications import router as notifications_router
-from meshbay_hub.api.relay import router as relay_router
from meshbay_hub.api.revocation import router as revocation_router
from meshbay_hub.api.signaling import router as signaling_router
from meshbay_hub.api.users import router as users_router
@@ -41,7 +39,6 @@ from meshbay_hub.api.webapp import configure as webapp_configure
from meshbay_hub.api.webapp import router as webapp_router
from meshbay_hub.auth import generate_hub_keypair, load_hub_keypair
from meshbay_hub.config import HubConfig
-from meshbay_hub.csam import csam_router
from meshbay_hub.db.engine import close_db, init_db
@@ -129,9 +126,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
if cfg.identity.admin_usernames:
await _sync_admin_roles(cfg.identity.admin_usernames)
- from meshbay_hub.csam import get_csam_checker
- get_csam_checker().load()
-
from meshbay_hub.db.engine import get_session_factory
from meshbay_hub.tasks.cleanup import cleanup_loop
cleanup_task = asyncio.create_task(cleanup_loop(get_session_factory()))
@@ -203,13 +197,10 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(groups_router)
app.include_router(invite_links_router)
app.include_router(invite_links_redeem_router)
- app.include_router(swarm_router)
app.include_router(revocation_router)
app.include_router(moderation_router)
app.include_router(federation_router)
- app.include_router(csam_router)
app.include_router(health_router)
- app.include_router(relay_router)
app.include_router(signaling_router)
app.include_router(admin_router)
app.include_router(notifications_router)
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index e182ba0..0d0fd95 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -241,7 +241,7 @@ def issue_access_token(
def issue_mnp_token(user_id: str, groups: list[str] | None = None,
- ttl: int = 900) -> str:
+ node_pk: str | None = None, ttl: int = 900) -> str:
"""Issue the short-lived token a member presents to a node in the handshake.
`aud=MNP_AUD`, so it is accepted by `authorize_token` and refused by the hub
@@ -249,6 +249,11 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None,
lifetime does not interrupt a long transfer or a film already playing; only a
fresh connection or a reconnect needs a fresh one. It carries the same
`sub`/`groups`/`jti` the node authorises and denylists on.
+
+ `node` names the node this token is for (its base64 Ed25519 key), so it
+ cannot be replayed to another node the member also belongs to — the node
+ checks it in `authorize_token` (E10). The client knows the target node's key
+ before it connects and asks for a token bound to it.
"""
if _hub_sk_pem is None:
raise RuntimeError("Hub keypair not loaded")
@@ -260,6 +265,7 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None,
"iat": now,
"exp": now + ttl,
"groups": groups or [],
+ "node": node_pk or "",
"scope": "user",
"aud": MNP_AUD,
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/csam.py b/packages/meshbay-hub/src/meshbay_hub/csam.py
deleted file mode 100644
index b8e8d04..0000000
--- a/packages/meshbay-hub/src/meshbay_hub/csam.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""
-MeshBay Hub — CSAM hash matching.
-
-Checks public content hashes against known CSAM (Child Sexual Abuse Material)
-hash databases before allowing content to be registered or served publicly.
-
-Production integration:
- - NCMEC (National Center for Missing & Exploited Children): PhotoDNA hash database
- Access requires formal application: https://www.missingkids.org/gethelpnow/cybertipline
- - IWF (Internet Watch Foundation): URL and hash list (UK-based)
- Access via IWF membership: https://www.iwf.org.uk/our-technology/our-products/hash-list/
-
-This module provides:
- 1. A local CSAM hash database (SQLite file, populated from official sources)
- 2. A check function used before content registration
- 3. An admin endpoint to update the hash list
-
-IMPORTANT: Never log matched hashes or file contents. CSAM detection
-must be reported to NCMEC (US law) or relevant authority immediately.
-"""
-
-import logging
-from pathlib import Path
-
-from fastapi import APIRouter, Depends, HTTPException
-
-from meshbay_hub.api.deps import require_admin
-from meshbay_hub.db.models import User
-
-log = logging.getLogger(__name__)
-
-# Default path for the CSAM hash database (blake3 hex hashes, one per line)
-DEFAULT_CSAM_DB_PATH = Path("/var/lib/meshbay/hub/csam_hashes.txt")
-
-
-class CSAMChecker:
- """
- Checks content hashes against a known CSAM hash database.
-
- Usage:
- checker = CSAMChecker()
- checker.load()
- if checker.is_known_csam(blake3_hex):
- # refuse to serve, report to authority
- pass
- """
-
- def __init__(self, db_path: Path = DEFAULT_CSAM_DB_PATH):
- self._db_path = db_path
- self._hashes: set[str] = set()
- self._loaded = False
-
- def load(self, db_path: Path | None = None) -> int:
- """
- Load CSAM hashes from the hash database file.
- Returns the number of hashes loaded.
-
- File format: one blake3 hex hash per line (64 chars), comments with #.
- """
- path = db_path or self._db_path
- if not path.exists():
- log.warning("CSAM hash database not found: %s. "
- "Contact NCMEC (US) or IWF (EU) for access.", path)
- self._loaded = True
- return 0
-
- count = 0
- with open(path) as f:
- for line in f:
- line = line.strip()
- if line and not line.startswith("#") and len(line) == 64:
- self._hashes.add(line.lower())
- count += 1
-
- self._loaded = True
- log.info("CSAM hash database loaded: %d hashes from %s", count, path)
- return count
-
- def is_known_csam(self, content_hash_hex: str) -> bool:
- """
- Return True if the hash matches a known CSAM hash.
- NEVER logs the hash or any file information.
- """
- if not self._loaded:
- self.load()
- return content_hash_hex.lower() in self._hashes
-
- @property
- def hash_count(self) -> int:
- return len(self._hashes)
-
- def add_hash(self, hash_hex: str) -> None:
- """Add a hash to the in-memory set (and optionally persist)."""
- self._hashes.add(hash_hex.lower())
-
- def update_from_file(self, new_db_path: Path) -> int:
- """Hot-reload from a new hash database file."""
- old_count = len(self._hashes)
- self._hashes.clear()
- count = self.load(new_db_path)
- log.info("CSAM database updated: %d → %d hashes", old_count, count)
- return count
-
-
-# Module-level singleton (initialised in hub lifespan)
-_checker = CSAMChecker()
-
-
-def get_csam_checker() -> CSAMChecker:
- return _checker
-
-
-def check_content_hash(blake3_hex: str) -> bool:
- """
- Check a content hash against the CSAM database.
- Returns True if the content is KNOWN CSAM — block immediately.
-
- Callers MUST:
- 1. Refuse to serve the content
- 2. Log the event (without the hash) for legal audit purposes
- 3. Report to NCMEC CyberTipline if operating in the US:
- https://www.missingkids.org/gethelpnow/cybertipline
- """
- return _checker.is_known_csam(blake3_hex)
-
-
-# ── Hub API integration ───────────────────────────────────────────────────────
-
-csam_router = APIRouter(prefix="/v1/admin/csam", tags=["csam"])
-
-
-@csam_router.get("/status")
-async def csam_status(current_user: User = Depends(require_admin)):
- """Return CSAM checker status (hash count, database path)."""
- return {
- "hash_count": _checker.hash_count,
- "db_path": str(_checker._db_path),
- "loaded": _checker._loaded,
- "note": "Contact NCMEC or IWF for hash database access.",
- }
-
-
-@csam_router.post("/check")
-async def check_hash(
- body: dict,
- current_user: User = Depends(require_admin),
-):
- """
- Check a single hash. Admin use only.
- Returns True/False WITHOUT logging the hash (legal requirement).
- """
- hash_hex = body.get("hash", "")
- if len(hash_hex) != 64:
- raise HTTPException(status_code=422, detail="hash must be 64 hex chars")
- matched = check_content_hash(hash_hex)
- # Do NOT log whether a match was found — only log the check attempt
- log.info("CSAM check performed by admin %s", current_user.username)
- return {"matched": matched}
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py
new file mode 100644
index 0000000..0e61390
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py
@@ -0,0 +1,38 @@
+"""drop the public-content swarm table
+
+Nodes registered the hashes of their public groups here and nothing ever read
+them back: the swarm was written and never used.
+
+Revision ID: e6f7a8b9c0d1
+Revises: c4d5e6f7a8b9
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "e6f7a8b9c0d1"
+down_revision: str | Sequence[str] | None = "c4d5e6f7a8b9"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.drop_index("ix_swarm_hash", table_name="swarm_sources")
+ op.drop_table("swarm_sources")
+
+
+def downgrade() -> None:
+ op.create_table(
+ "swarm_sources",
+ sa.Column("content_hash", sa.String(64), nullable=False),
+ sa.Column("node_id", sa.String(36), nullable=False),
+ sa.Column("endpoint", sa.String(128), nullable=False),
+ sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False,
+ server_default=sa.func.now()),
+ sa.Column("last_seen", sa.DateTime(timezone=True), nullable=False,
+ server_default=sa.func.now()),
+ sa.PrimaryKeyConstraint("content_hash", "node_id"),
+ )
+ op.create_index("ix_swarm_hash", "swarm_sources", ["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 09eb437..5b140f1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -266,22 +266,6 @@ class UserDevice(Base):
__table_args__ = (Index("ix_user_devices_user", "user_id"),)
-class SwarmSource(Base):
- """
- Tracks which nodes can serve a given content hash (public swarm).
- Hub maintains this for load-balanced public content delivery.
- """
- __tablename__ = "swarm_sources"
-
- content_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
- node_id: Mapped[str] = mapped_column(String(36), primary_key=True)
- endpoint: Mapped[str] = mapped_column(String(128), nullable=False)
- registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
- last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
-
- __table_args__ = (Index("ix_swarm_hash", "content_hash"),)
-
-
class Notification(Base):
__tablename__ = "notifications"
@@ -341,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/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 6b20a63..875b1aa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -3,7 +3,7 @@
import { clearPending, loadPending } from './invite-link.js';
import {
html, render, useState, useEffect, useLayoutEffect, useCallback, useRef,
- createContext, useContext,
+ createContext,
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
@@ -72,7 +72,6 @@ function useRoute() {
// ── Context ──────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
-function useAuth() { return useContext(AuthContext); }
// The M of the wordmark is a picture; the rest is text. Resolved from this
// module's own URL so the hub's fingerprinted path and the application's
@@ -304,6 +303,7 @@ function TransferRow({ it }) {
</button>
`}
</div>
+ ${it.note && html`<div class="transfer-meta transfer-note">${it.note}</div>`}
${it.status === 'preparing'
? html`
${/* Not a progress bar at 0%: nothing is wrong and nothing is
@@ -645,10 +645,6 @@ function LazyCreateGroupPage(props) {
return html`<${_CreateGroupPage} ...${props} />`;
}
-// ── Settings Page ───────────────────────────────────────────────────────────
-
-const THEME_OPTIONS = ['light', 'dark', 'system'];
-
// ── Profile Page ────────────────────────────────────────────────────────────
//
// ── Lazy-loaded Admin page (admin/moderator only) ─────────────────────────
@@ -924,8 +920,31 @@ function App() {
.catch(() => {});
}, [user, notifDisabled]);
+ // Whether this account has a node linked, which is what shows the sidebar's
+ // Node section. Asked again whenever a node may just have been linked (the
+ // Create Group wizard), not only when the session changes -- the section
+ // stayed hidden until a reload after the first group -- and retried rather
+ // than left false by one failed request after a session blip.
+ const nodeKeyAskedForRef = useRef(null);
+ const refreshNodeKey = useCallback(() => {
+ nodeKeyAskedForRef.current = user;
+ if (!user || !platform.capabilities.nodeAdmin) return;
+ // A late answer, or a retry, must not land on a session that has changed.
+ const current = () => nodeKeyAskedForRef.current === user;
+ const ask = (attempt) => hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
+ .then(data => { if (current()) setHasNodeKey(Boolean(data.pk_node_ed25519)); })
+ .catch(() => {
+ if (attempt < 3 && current()) setTimeout(() => ask(attempt + 1), 2000 * attempt);
+ });
+ ask(1);
+ }, [user]);
+
useEffect(() => {
- if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); return; }
+ if (!user) {
+ nodeKeyAskedForRef.current = null;
+ setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false);
+ return;
+ }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
@@ -935,11 +954,7 @@ function App() {
if (prefs.notifications_disabled === 'true') setNotifDisabled(true);
})
.catch(() => {});
- if (platform.capabilities.nodeAdmin) {
- hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
- .then(data => setHasNodeKey(Boolean(data.pk_node_ed25519)))
- .catch(() => {});
- }
+ refreshNodeKey();
fetchNotifications();
}, [user]);
@@ -1176,10 +1191,12 @@ function App() {
} else if (route === '/create-group') {
page = html`<${LazyCreateGroupPage} token=${user.token} username=${user.username}
allowPublicGroups=${allowPublicGroups}
+ onNodeLinked=${refreshNodeKey}
onCreated=${() => {
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => {});
+ refreshNodeKey();
}} />`;
} else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 2164eae..09c4acf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -344,6 +344,23 @@ function WelcomePitch() {
`;
}
+// The sign-in page's dark gradient backdrop and frosted card, without the
+// pitch beside it — so Register (and its verify/recovery/done steps) sits on
+// the same background and reads in the same dark theme as Login. A lone
+// `.welcome-side` is centred by `.welcome`'s `justify-content`.
+function AuthShell({ children }) {
+ return html`
+ <div class="page-center">
+ <div class="welcome-backdrop" aria-hidden="true"></div>
+ <div class="welcome">
+ <div class="welcome-side">
+ ${children}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
export function RegisterPage() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
@@ -358,7 +375,9 @@ export function RegisterPage() {
const [recoveryMnemonic, setRecoveryMnemonic] = useState('');
const [recoverySaved, setRecoverySaved] = useState(false);
const [recoveryCopied, setRecoveryCopied] = useState(false);
- const [emailRecovery, setEmailRecovery] = useState(true);
+ // Off by default: mailing the recovery key is opt-in — the key is shown on
+ // screen to save, and sending a copy is the user's own choice to make.
+ const [emailRecovery, setEmailRecovery] = useState(false);
const captcha = useCaptcha();
const onSubmit = async (e) => {
@@ -457,7 +476,7 @@ export function RegisterPage() {
if (phase === 'done') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.verified_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -467,7 +486,7 @@ export function RegisterPage() {
<p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`}
<a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
</div>
- </div>
+ </${AuthShell}>
`;
}
@@ -480,7 +499,7 @@ export function RegisterPage() {
} catch { /* clipboard blocked — the text is on screen to copy by hand */ }
};
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.recovery_title')}</h2>
<p style="margin-bottom:12px; color:var(--text-secondary)">
@@ -509,13 +528,13 @@ export function RegisterPage() {
${t('register.recovery_continue')}
</button>
</div>
- </div>
+ </${AuthShell}>
`;
}
if (phase === 'verify') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.success_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -538,12 +557,12 @@ export function RegisterPage() {
${t('register.resend_sent')}</span>`}
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.title')}</h2>
<form onSubmit=${onSubmit}>
@@ -585,7 +604,7 @@ export function RegisterPage() {
${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a>
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
index aac8225..15d352a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
@@ -78,7 +78,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user
const ack = await Promise.race([
transport.connect(
n.node_id, live, groupId, null, null, bundleKey,
- username, userId, null),
+ username, userId, null, undefined, undefined, n.pk_node),
new Promise((_, reject) => {
const giveUp = () => reject(new Error('Connection timeout'));
stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index 85fe53c..7556010 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -124,7 +124,7 @@ function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) {
`;
}
-function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) {
+function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPublicGroups = true }) {
const [step, setStep] = useState(0);
const [nodeStatus, setNodeStatus] = useState(null);
const [nodeStarting, setNodeStarting] = useState(false);
@@ -151,7 +151,10 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token, body: { pk_node_ed25519: pk },
});
- }, [token]);
+ // The sidebar's Node section depends on this link; it only re-read it at
+ // sign-in, so the first node set up here stayed out of it until a reload.
+ if (onNodeLinked) onNodeLinked();
+ }, [token, onNodeLinked]);
const detectNode = useCallback(async () => {
setNodeStatus(null);
@@ -177,6 +180,8 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
setError('');
try {
const result = await platform.node.start({ hubUrl: HUB, username, token });
+ // node.start links the key from the main process, out of this page's sight.
+ if (onNodeLinked) onNodeLinked();
setNodeStatus({ detected: true, ...result });
setNodeStarting(false);
setStep(1);
@@ -337,6 +342,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
&& !provisionAttempted.current
&& (nodeStatus.status === 'waiting_for_account'
|| nodeStatus.status === 'waiting_for_node_key'
+ || nodeStatus.status === 'waiting_for_hub'
|| nodeStatus.status === 'starting')) {
provisionAttempted.current = true;
startNode();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index a3680ce..0ca8ee5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -1,20 +1,12 @@
/**
- * MeshBay Browser Crypto — AES-256-GCM private group decryption.
- * Uses WebCrypto SubtleCrypto API (available in all modern browsers).
- *
- * Handles groups with cipher="aes-256-gcm" (browser-accessible groups).
- * ChaCha20-Poly1305 groups (cipher="chacha20-poly1305") require the
- * native client (node) for decryption — not supported in browser.
+ * MeshBay Browser Crypto — AES-256-GCM, through WebCrypto's SubtleCrypto API.
+ * The one content cipher, for every client (meshbay_common/webcrypto.py).
*
* Usage:
* const gek = await importGEK(gekB64);
* const plaintext = await decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct);
*/
-const CIPHER_INFO_PREFIX = new TextEncoder().encode('file:');
-const CIPHER_INFO_SUFFIX_AES = new TextEncoder().encode(':aes');
-
-
// ── Key derivation ────────────────────────────────────────────────────────────
/**
@@ -284,38 +276,7 @@ async function verifyChatSignature(deviceRaw, groupId, epoch, nonce, ct, sig) {
}
-// ── GEK generation + ECIES wrapping ──────────────────────────────────────────
-
-function generateGEK() {
- return crypto.getRandomValues(new Uint8Array(32));
-}
-
-async function wrapGEK(gek, pkXRaw) {
- const skEph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
- const pkEphRaw = new Uint8Array(await crypto.subtle.exportKey('raw', skEph.publicKey));
-
- const pkRecip = await crypto.subtle.importKey('raw', pkXRaw, { name: 'X25519' }, false, []);
- const sharedBits = await crypto.subtle.deriveBits(
- { name: 'X25519', public: pkRecip }, skEph.privateKey, 256);
-
- const sharedKey = await crypto.subtle.importKey(
- 'raw', sharedBits, 'HKDF', false, ['deriveKey']);
- const wrapKey = await crypto.subtle.deriveKey(
- { name: 'HKDF', hash: 'SHA-256', salt: pkEphRaw,
- info: new TextEncoder().encode('meshbay:gek_wrap:v1:aes') },
- sharedKey,
- { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
-
- const nonce = crypto.getRandomValues(new Uint8Array(12));
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv: nonce, additionalData: pkXRaw }, wrapKey, gek);
-
- return {
- pk_eph_b64: btoa(String.fromCharCode(...pkEphRaw)),
- nonce_b64: btoa(String.fromCharCode(...nonce)),
- wrapped_b64: btoa(String.fromCharCode(...new Uint8Array(ct))),
- };
-}
+// ── GEK unwrapping (ECIES) ─────────────────────────────────────────────────────
async function unwrapGEK(bundle, skXPkcs8, pkXRaw) {
const pkEphRaw = b64decode(bundle.pk_eph_b64);
@@ -342,18 +303,45 @@ async function unwrapGEK(bundle, skXPkcs8, pkXRaw) {
return new Uint8Array(plain);
}
-// ── Chunk encryption (for upload) ────────────────────────────────────────────
+function b64encode(bytes) {
+ return btoa(String.fromCharCode(...bytes));
+}
-async function encryptChunk(gek, fileHashHex, chunkIndex, plaintext) {
- const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex);
- const nonce = crypto.getRandomValues(new Uint8Array(12));
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv: nonce }, chunkKey, plaintext);
- return { nonce, ct: new Uint8Array(ct) };
+// ── Admin operation subjects ────────────────────────────────────────────────
+// Mirrors meshbay_common/adminop.py. The subject is what the signature covers of
+// a request, so an operation whose effect is several values names them all.
+// Canonical JSON — sorted keys, no whitespace — so both sides build the same
+// bytes, and `null`, `""` and a value stay distinct.
+
+function adminSubject(fields) {
+ const sorted = {};
+ for (const k of Object.keys(fields).sort()) sorted[k] = fields[k];
+ return JSON.stringify(sorted);
}
-function b64encode(bytes) {
- return btoa(String.fromCharCode(...bytes));
+// A secret named without being written: null (unchanged) and '' (clear) as
+// themselves, anything else as its SHA-256.
+async function secretDigest(value) {
+ if (!value) return value;
+ const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
+ return 'sha256:' + Array.from(new Uint8Array(d))
+ .map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+function rootAddSubject(path, name, kind, writable, removable) {
+ return adminSubject({ path, name, kind, writable, removable });
+}
+
+function groupAttachSubject(name, sharedDir, writable) {
+ return adminSubject({ name, shared_dir: sharedDir, writable });
+}
+
+function inviteCreateSubject(userId, username) {
+ return adminSubject({ user_id: userId, username });
+}
+
+async function tmdbConfigSubject(token, language) {
+ return adminSubject({ token: await secretDigest(token), language });
}
// ── Admin operation transcript ───────────────────────────────────────────────
@@ -583,8 +571,9 @@ async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunkBin,
openGroup, sealGroup,
- generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
- adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
+ unwrapGEK, b64encode, b64decode,
+ adminTranscript, adminSubject, rootAddSubject, groupAttachSubject,
+ inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding,
challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual,
deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript,
deviceCodeHash,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
index 9c23d3c..50bca46 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -193,13 +193,6 @@ export async function openTarget(filename) {
};
}
-/**
- * Below this, a download with no granted folder and no service worker is
- * collected in memory and handed to the browser. Above it that would mean
- * holding gigabytes in a tab, so it is worth one Save As dialog instead.
- */
-export const BLOB_LIMIT = 512 * 1024 * 1024;
-
// ── Streaming to disk without the File System Access API ────────────────────
const SW_PATH = '/sw.js';
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
index bdfba6e..255c162 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -3,6 +3,7 @@ import * as platform from './platform.js';
import { t } from './i18n.js';
import { ask } from './ask.js';
import { ZipStream, entriesUnder } from './zipstream.js';
+import { portableName, portablePath } from './portable-name.js';
const FILE_ICONS = {
video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}',
@@ -428,6 +429,9 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
*/
async function downloadEntry(transfers, transport, gek, entry) {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ // Saved under a name every platform can write, and the row says so when that
+ // is not the node's (portable-name.js, docs/MESHBAY_DESIGN.md §10).
+ const saveName = portableName(entry.name);
const openRef = { url: null };
let target = null;
// The in-memory fallback's accumulator, held out here so a pause does not
@@ -435,14 +439,15 @@ async function downloadEntry(transfers, transport, gek, entry) {
const memoryChunks = new Array(totalChunks);
transfers.start({
- kind: 'download', name: entry.name, total: entry.size, transport,
+ kind: 'download', name: saveName, total: entry.size, transport,
+ note: saveName !== entry.name ? t('transfers.renamed', { name: entry.name }) : '',
// The row exists from the click. Opening a target is what takes the time —
// the streamed path waits for the worker (twice), a Save As dialog waits
// for a person — and doing it before the row meant three clicks produced no
// panel at all and then several rows at once.
prepare: async () => {
- target = await _openTargetInTurn(entry.name, entry.size);
+ target = await _openTargetInTurn(saveName, entry.size);
// Dismissed: nothing was started, so nothing is left on screen.
if (target === false) return false;
// `pausable` travels with the target, because only the target knows. The
@@ -489,7 +494,7 @@ async function downloadEntry(transfers, transport, gek, entry) {
transport, gek, entry.id, totalChunks, onChunk, null, signal,
lease && lease.tr, from, memoryChunks);
const blob = new Blob(chunks);
- _saveBlob(blob, entry.name);
+ _saveBlob(blob, saveName);
openRef.url = URL.createObjectURL(blob);
}
},
@@ -523,7 +528,10 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
return;
}
const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
- const suggested = (dir.split('/').pop() || 'files') + '.zip';
+ const suggested = portableName(dir.split('/').pop() || 'files') + '.zip';
+ // Every name in the archive is made writable everywhere, or a Windows
+ // extraction refuses it; the row says how many changed.
+ const renamed = files.filter(f => portablePath(f.name) !== f.name).length;
// Checked here rather than by disabling the button: Files zips a whole
// multi-directory selection in one click (`for (const d of selectedDirs)`),
@@ -548,6 +556,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
transfers.start({
kind: 'download', name: suggested, total: totalBytes, transport,
+ note: renamed ? t('transfers.renamed_n', { n: renamed }) : '',
// Same order as downloadEntry: the row first, then the target, then the
// slot. A folder of forty files is exactly where the wait is longest.
@@ -584,7 +593,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
});
for (const { entry, name } of files) {
- await zip.begin(name, entry.size,
+ await zip.begin(portablePath(name), entry.size,
new Date((entry.added_at || 0) * 1000));
// A zero-byte file has no chunk to ask for; the header and an empty
// descriptor are the whole entry.
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 ac978e2..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);
};
@@ -893,7 +908,6 @@ function FilesPanel({
// ── File Preview (text, images) ─────────────────────────────────────────
-const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i;
function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
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 2ee6e05..b6f3d37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -16,8 +16,9 @@ import { FilePreview } from './files-app.js';
import { lazy } from './lazy.js';
// Fetched the first time a video is played / the Settings tab is opened.
+const playerFrame = (content) => html`<div class="video-overlay video-player-overlay">${content}</div>`;
const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer',
- html`<div class="video-overlay"><p class="page-message"><span class="spinner"></span></p></div>`);
+ playerFrame(html`<p class="page-message"><span class="spinner"></span></p>`), playerFrame);
const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSettingsPanel');
import { reportIndexPush } from './index-dock.js';
import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
@@ -433,7 +434,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
try {
ack = await transport.connect(
n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
- username, userId, joinCode, session.recoveryKey, joinNodePk);
+ username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node);
break;
} catch (e) {
lastErr = e;
@@ -833,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/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index a510e63..fba8a0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -417,7 +417,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// Whether the hub mails the invitation. Checked, the hub is handed the code
// to write it into the mail — so it is the inviter's choice, remembered per
// account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it.
- const [inviteByEmail, setInviteByEmail] = useState(true);
+ // Off by default: mailing the code is opt-in, not something to do unasked.
+ const [inviteByEmail, setInviteByEmail] = useState(false);
const [error, setError] = useState('');
// Node loopback state (Electron-only)
@@ -811,7 +812,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
useEffect(() => {
hubFetch('/v1/users/me/preferences', { token })
- .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false'))
+ .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true'))
.catch(() => {});
}, [token]);
@@ -1059,7 +1060,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${inviting ? '...' : t('members.invite_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
@@ -1105,7 +1106,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${linking ? '...' : t('members.link_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 56d35f7..bc78266 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -131,10 +131,6 @@ export function setLocale(code) {
return true;
}
-export function addLocale(code, strings) {
- _strings[code] = strings;
-}
-
function _pluralRules(locale) {
if (!_plurals[locale]) _plurals[locale] = new Intl.PluralRules(locale);
return _plurals[locale];
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 879f56f..33b1cf2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -256,15 +256,9 @@ async function deriveRecoveryKey(R, username) {
// ── Bundle encryption ─────────────────────────────────────────────────────────
/**
- * Encrypt the keypair bundle with the password-derived AES key.
- * Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) }
+ * Encrypt the keypair bundle with a bundle key derived at sign-in. Always
+ * writes v2. Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) }
*/
-async function encryptBundle(skEdRaw, skXRaw, password, username) {
- const aesKey = await deriveEncryptionKey(password, username);
- return encryptBundleWithKey(skEdRaw, skXRaw, aesKey);
-}
-
-/** Same, when the key was already derived at sign-in. Always writes v2. */
async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(JSON.stringify({
@@ -289,16 +283,6 @@ function bundleVersion(bundleB64) {
} catch { return 1; }
}
-/**
- * Decrypt a keypair bundle. Throws if password is wrong.
- */
-async function decryptBundle(bundleB64, password, username) {
- const key = bundleVersion(bundleB64) === 2
- ? await deriveEncryptionKey(password, username)
- : await deriveEncryptionKeyV1(password, username);
- return decryptBundleWithKey(bundleB64, key);
-}
-
// ── Registration ──────────────────────────────────────────────────────────────
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
index 30a564c..fda73c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
@@ -9,17 +9,45 @@
//
// A failed fetch (a network drop on a phone) is not remembered: the next time
// the component is shown it asks again, rather than failing for the session.
+//
+// A failed fetch is also *said*, never left as the placeholder's spinner. The
+// commonest failure is not the network: the hub serves the module graph under
+// `/a/<hash>/` for the current hash only, so a tab opened before a hub deploy
+// asks for every module it has not loaded yet under a prefix that now answers
+// 404. Such a tab kept Files and Chat (already loaded) and spun for ever on
+// Search, Videos, Music, Photos and the player — with nothing in the console,
+// because the rejection was swallowed here. Asking again cannot help that tab;
+// reloading the page is the one thing that does, so that is what is offered.
+// Not done automatically: a reload that fails the same way would loop, and it
+// would throw away whatever the reader had in progress.
import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
const SPINNER = html`<p class="page-message"><span class="spinner"></span></p>`;
-function lazy(load, name, placeholder = SPINNER) {
+function LoadFailed({ onClose }) {
+ return html`
+ <div class="page-message lazy-failed" role="alert">
+ <p>${t('lazy.load_failed')}</p>
+ <button class="admin-btn" onClick=${() => location.reload()}>${t('lazy.reload')}</button>
+ ${onClose && html`
+ ${' '}<button class="admin-btn" onClick=${onClose}>${t('admin.btn_close')}</button>
+ `}
+ </div>
+ `;
+}
+
+// `frame` wraps the failure notice, so a component that is an overlay (the
+// video player) can put it where the overlay would have been rather than
+// somewhere at the bottom of the page, under nothing anyone is looking at.
+function lazy(load, name, placeholder = SPINNER, frame = (content) => content) {
let Loaded = null;
let pending = null;
function Lazy(props) {
const [, setReady] = useState(Loaded !== null);
+ const [failed, setFailed] = useState(false);
useEffect(() => {
if (Loaded) return undefined;
let alive = true;
@@ -28,11 +56,15 @@ function lazy(load, name, placeholder = SPINNER) {
(m) => { Loaded = m[name]; },
(e) => { pending = null; throw e; });
}
- pending.then(() => { if (alive) setReady(true); }, () => {});
+ pending.then(() => { if (alive) setReady(true); }, (e) => {
+ console.error(`[MeshBay] could not load ${name}:`, e);
+ if (alive) setFailed(true);
+ });
return () => { alive = false; };
}, []);
- if (!Loaded) return placeholder;
- return html`<${Loaded} ...${props} />`;
+ if (Loaded) return html`<${Loaded} ...${props} />`;
+ if (failed) return frame(html`<${LoadFailed} onClose=${props.onClose} />`);
+ return placeholder;
}
Lazy.displayName = `Lazy(${name})`;
return Lazy;
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 7bd5169..a83f44b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "Benutzername oder Passphrase falsch.",
'login.locked': "Zu viele falsche Passphrasen für dieses Konto. Versuchen Sie es in {minutes} Min. erneut oder setzen Sie Ihre Passphrase zurück.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Das Internet, wie es gedacht war.',
+ 'welcome.title': 'Das Netzwerk, das nur von Ihnen abhängt.',
'welcome.lead': 'Ihre Fotos, Musik, Videos und Unterhaltungen bleiben zu Hause, auf Ihrem eigenen Computer. Mit MeshBay genießen Sie sie von überall und teilen sie mit den Menschen Ihrer Wahl.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
@@ -888,6 +888,8 @@ export default {
// Group misc
'group.retry': 'Erneut versuchen',
+ 'lazy.load_failed': 'Dieser Teil der Oberfläche konnte nicht geladen werden. Der Hub wurde möglicherweise aktualisiert, seit diese Seite geöffnet wurde.',
+ 'lazy.reload': 'Seite neu laden',
// Sidebar
'sidebar.node': 'Node',
@@ -910,7 +912,7 @@ export default {
'node.service_restarting': 'Wird neu gestartet…',
'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Aus (manuell starten)',
+ 'node.startup_mode_off': 'Nur solange MeshBay geöffnet ist',
'node.startup_mode_signin': 'Bei der Anmeldung',
'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)',
'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…',
@@ -1192,4 +1194,32 @@ 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",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Umbenannt: „{name}“ ist nicht auf jedem System ein gültiger Name",
+ 'transfers.renamed_n': "Namen geändert, damit sie auf jedem System gültig sind: {n}",
};
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 0c9cb19..46c5094 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -95,7 +95,7 @@ export default {
'login.invalid': "Wrong username or passphrase.",
'login.locked': "Too many wrong passphrases for this account. Try again in {minutes} min, or reset your passphrase.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'The Internet as it was meant to be.',
+ 'welcome.title': 'The network that relies only on you.',
'welcome.lead': 'Your photos, music, videos and conversations stay at home, on your own computer. MeshBay lets you enjoy them from anywhere, and share them with the people you choose.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Photos',
@@ -984,6 +984,8 @@ export default {
'group.mute': 'Mute notifications',
'group.unmute': 'Unmute notifications',
'group.retry': 'Retry',
+ 'lazy.load_failed': 'This part of the interface could not be loaded. The hub may have been updated since this page was opened.',
+ 'lazy.reload': 'Reload the page',
'profile.title': 'Profile',
'usermenu.profile': 'Profile',
@@ -1006,7 +1008,7 @@ export default {
'node.service_restarting': 'Restarting…',
'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.',
'node.startup_mode_label': 'Start automatically:',
- 'node.startup_mode_off': 'Off (start manually)',
+ 'node.startup_mode_off': 'Only while MeshBay is open',
'node.startup_mode_signin': 'At sign-in',
'node.startup_mode_service': 'As a background service (starts at boot)',
'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…',
@@ -1173,4 +1175,32 @@ 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",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renamed: “{name}” is not a valid name on every system",
+ 'transfers.renamed_n': "Names changed to be valid on every system: {n}",
};
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 1399a83..0d0e865 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -91,7 +91,7 @@ export default {
'login.invalid': "Nombre de usuario o frase de contraseña incorrectos.",
'login.locked': "Demasiadas frases de contraseña incorrectas para esta cuenta. Vuelva a intentarlo en {minutes} min o restablezca su frase de contraseña.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Internet como debió ser.',
+ 'welcome.title': 'La red que solo depende de ti.',
'welcome.lead': 'Sus fotos, su música, sus vídeos y sus conversaciones se quedan en casa, en su propio ordenador. MeshBay le permite disfrutarlos desde cualquier lugar y compartirlos con las personas que usted elija.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
@@ -882,6 +882,8 @@ export default {
// Group
'group.retry': 'Reintentar',
+ 'lazy.load_failed': 'No se pudo cargar esta parte de la interfaz. Es posible que el hub se haya actualizado desde que se abrió esta página.',
+ 'lazy.reload': 'Recargar la página',
// Sidebar
'sidebar.node': 'Node',
@@ -904,7 +906,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.',
'node.startup_mode_label': 'Iniciar automáticamente:',
- 'node.startup_mode_off': 'Desactivado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Solo mientras MeshBay está abierto',
'node.startup_mode_signin': 'Al iniciar sesión',
'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)',
'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…',
@@ -1186,4 +1188,32 @@ 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",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renombrado: «{name}» no es un nombre válido en todos los sistemas",
+ 'transfers.renamed_n': "Nombres cambiados para ser válidos en todos los sistemas: {n}",
};
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 ae278d9..3c86cd7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -91,7 +91,7 @@ export default {
'login.invalid': "Nom d'utilisateur ou phrase secrète incorrect.",
'login.locked': "Trop de phrases secrètes erronées pour ce compte. Réessayez dans {minutes} min, ou réinitialisez votre phrase secrète.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'L’Internet tel qu’il aurait dû être.',
+ 'welcome.title': 'Le réseau qui ne dépend que de vous.',
'welcome.lead': 'Vos photos, votre musique, vos vidéos et vos conversations restent chez vous, sur votre propre ordinateur. MeshBay vous permet d’en profiter de partout, et de les partager avec les personnes de votre choix.',
'welcome.app_chat': 'Messagerie',
'welcome.app_photos': 'Photos',
@@ -885,6 +885,8 @@ export default {
// Group
'group.retry': 'Réessayer',
+ 'lazy.load_failed': 'Cette partie de l\'interface n\'a pas pu être chargée. Le hub a peut-être été mis à jour depuis l\'ouverture de cette page.',
+ 'lazy.reload': 'Recharger la page',
// Sidebar
'sidebar.node': 'Node',
@@ -907,7 +909,7 @@ export default {
'node.service_restarting': 'Redémarrage…',
'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.',
'node.startup_mode_label': 'Démarrer automatiquement :',
- 'node.startup_mode_off': 'Désactivé (démarrage manuel)',
+ 'node.startup_mode_off': 'Uniquement quand MeshBay est ouvert',
'node.startup_mode_signin': 'À l\'ouverture de session',
'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)',
'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…',
@@ -1201,4 +1203,32 @@ 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",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renommé : « {name} » n’est pas un nom valide sur tous les systèmes",
+ 'transfers.renamed_n': "Noms modifiés pour être valides sur tous les systèmes : {n}",
};
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 f0fb0d1..3a74b4d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "Nome utente o passphrase errati.",
'login.locked': "Troppe passphrase errate per questo account. Riprovi tra {minutes} min oppure reimposti la passphrase.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Internet come doveva essere.',
+ 'welcome.title': 'La rete che dipende solo da te.',
'welcome.lead': 'Le sue foto, la sua musica, i suoi video e le sue conversazioni restano a casa, sul suo computer. MeshBay le permette di goderseli ovunque e di condividerli con le persone che sceglie.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Foto',
@@ -884,6 +884,8 @@ export default {
// Group misc
'group.retry': 'Riprova',
+ 'lazy.load_failed': 'Impossibile caricare questa parte dell\'interfaccia. L\'hub potrebbe essere stato aggiornato da quando questa pagina è stata aperta.',
+ 'lazy.reload': 'Ricarica la pagina',
// Sidebar
'sidebar.node': 'Node',
@@ -906,7 +908,7 @@ export default {
'node.service_restarting': 'Riavvio…',
'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.',
'node.startup_mode_label': 'Avvia automaticamente:',
- 'node.startup_mode_off': 'Disattivato (avvio manuale)',
+ 'node.startup_mode_off': 'Solo mentre MeshBay è aperto',
'node.startup_mode_signin': 'All\'accesso',
'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)',
'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…',
@@ -1200,4 +1202,32 @@ 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",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Rinominato: «{name}» non è un nome valido su tutti i sistemi",
+ 'transfers.renamed_n': "Nomi modificati per essere validi su tutti i sistemi: {n}",
};
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 560332d..e0c38de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "ユーザー名またはパスフレーズが正しくありません。",
'login.locked': "このアカウントでパスフレーズの誤りが多すぎます。{minutes} 分後にもう一度お試しいただくか、パスフレーズをリセットしてください。",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': '本来あるべき姿のインターネット。',
+ 'welcome.title': 'あなただけが支えるネットワーク。',
'welcome.lead': '写真、音楽、動画、会話は、ご自宅の自分のパソコンに置いたまま。MeshBay なら、どこからでも楽しめて、選んだ相手と共有できます。',
'welcome.app_chat': 'チャット',
'welcome.app_photos': '写真',
@@ -872,6 +872,8 @@ export default {
// Group
'group.retry': '再試行',
+ 'lazy.load_failed': 'インターフェースのこの部分を読み込めませんでした。このページを開いた後に hub が更新された可能性があります。',
+ 'lazy.reload': 'ページを再読み込み',
// Sidebar
'sidebar.node': 'Node',
@@ -894,7 +896,7 @@ export default {
'node.service_restarting': '再起動中…',
'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。',
'node.startup_mode_label': '自動的に開始:',
- 'node.startup_mode_off': 'オフ(手動で開始)',
+ 'node.startup_mode_off': 'MeshBay が開いている間のみ',
'node.startup_mode_signin': 'サインイン時',
'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)',
'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…',
@@ -1184,4 +1186,32 @@ 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': "報告の設定を保存",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "名前を変更しました:「{name}」はすべてのシステムで有効な名前ではありません",
+ 'transfers.renamed_n': "すべてのシステムで有効になるよう変更した名前:{n}",
};
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 adb94c6..83b9eed 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "Onjuiste gebruikersnaam of wachtwoordzin.",
'login.locked': "Te veel onjuiste wachtwoordzinnen voor dit account. Probeer het over {minutes} min opnieuw of stel uw wachtwoordzin opnieuw in.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Het internet zoals het bedoeld was.',
+ 'welcome.title': 'Het netwerk dat alleen van jou afhangt.',
'welcome.lead': 'Uw foto’s, muziek, video’s en gesprekken blijven thuis, op uw eigen computer. Met MeshBay geniet u er overal van en deelt u ze met wie u maar wilt.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Foto’s',
@@ -886,6 +886,8 @@ export default {
// Group
'group.retry': 'Opnieuw proberen',
+ 'lazy.load_failed': 'Dit deel van de interface kon niet worden geladen. De hub is mogelijk bijgewerkt sinds deze pagina werd geopend.',
+ 'lazy.reload': 'Pagina opnieuw laden',
// Sidebar
'sidebar.node': 'Node',
@@ -908,7 +910,7 @@ export default {
'node.service_restarting': 'Herstarten…',
'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Uit (handmatig starten)',
+ 'node.startup_mode_off': 'Alleen zolang MeshBay open is',
'node.startup_mode_signin': 'Bij aanmelden',
'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)',
'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…',
@@ -1202,4 +1204,32 @@ 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",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Hernoemd: ‘{name}’ is niet op elk systeem een geldige naam",
+ 'transfers.renamed_n': "Namen aangepast zodat ze op elk systeem geldig zijn: {n}",
};
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 fb5a4ed..3edba2d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -95,7 +95,7 @@ export default {
'login.invalid': "Nieprawidłowa nazwa użytkownika lub hasło-fraza.",
'login.locked': "Zbyt wiele błędnych haseł-fraz dla tego konta. Spróbuj ponownie za {minutes} min lub zresetuj hasło-frazę.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Internet taki, jaki miał być.',
+ 'welcome.title': 'Sieć, która zależy tylko od Ciebie.',
'welcome.lead': 'Twoje zdjęcia, muzyka, filmy i rozmowy zostają w domu, na Twoim własnym komputerze. MeshBay pozwala korzystać z nich z dowolnego miejsca i dzielić się nimi z wybranymi osobami.',
'welcome.app_chat': 'Czat',
'welcome.app_photos': 'Zdjęcia',
@@ -904,6 +904,8 @@ export default {
// Group (supplementary)
'group.retry': 'Ponów',
+ 'lazy.load_failed': 'Nie udało się wczytać tej części interfejsu. Hub mógł zostać zaktualizowany od czasu otwarcia tej strony.',
+ 'lazy.reload': 'Wczytaj stronę ponownie',
// Sidebar (supplementary)
'sidebar.node': 'Node',
@@ -926,7 +928,7 @@ export default {
'node.service_restarting': 'Ponowne uruchamianie…',
'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.',
'node.startup_mode_label': 'Uruchamiaj automatycznie:',
- 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)',
+ 'node.startup_mode_off': 'Tylko gdy MeshBay jest otwarty',
'node.startup_mode_signin': 'Przy logowaniu',
'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)',
'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…',
@@ -1228,4 +1230,32 @@ 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ń",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Zmieniono nazwę: „{name}” nie jest prawidłową nazwą w każdym systemie",
+ 'transfers.renamed_n': "Nazwy zmienione, by były prawidłowe w każdym systemie: {n}",
};
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 7bd11f6..3f44570 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
@@ -93,7 +93,7 @@ export default {
'login.invalid': "Nome de usuário ou frase secreta incorretos.",
'login.locked': "Muitas frases secretas incorretas para esta conta. Tente novamente em {minutes} min ou redefina sua frase secreta.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'A internet como deveria ser.',
+ 'welcome.title': 'A rede que só depende de você.',
'welcome.lead': 'Suas fotos, músicas, vídeos e conversas ficam em casa, no seu próprio computador. O MeshBay permite que você aproveite tudo de qualquer lugar e compartilhe com as pessoas que escolher.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
@@ -883,6 +883,8 @@ export default {
// Group misc
'group.retry': 'Tentar novamente',
+ 'lazy.load_failed': 'Não foi possível carregar esta parte da interface. O hub pode ter sido atualizado desde que esta página foi aberta.',
+ 'lazy.reload': 'Recarregar a página',
// Sidebar
'sidebar.node': 'Node',
@@ -905,7 +907,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.',
'node.startup_mode_label': 'Iniciar automaticamente:',
- 'node.startup_mode_off': 'Desativado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Somente enquanto o MeshBay estiver aberto',
'node.startup_mode_signin': 'Ao entrar na sessão',
'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)',
'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…',
@@ -1187,4 +1189,32 @@ 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",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renomeado: “{name}” não é um nome válido em todos os sistemas",
+ 'transfers.renamed_n': "Nomes alterados para serem válidos em todos os sistemas: {n}",
};
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 5509332..1168460 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
@@ -92,7 +92,7 @@ export default {
'login.invalid': "用户名或密码短语错误。",
'login.locked': "此账户输错密码短语的次数过多。请在 {minutes} 分钟后重试,或重置密码短语。",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': '互联网本该有的样子。',
+ 'welcome.title': '只依靠你自己的网络。',
'welcome.lead': '您的照片、音乐、视频和聊天都留在家里,保存在您自己的电脑上。MeshBay 让您随时随地享用它们,并与您选择的人分享。',
'welcome.app_chat': '聊天',
'welcome.app_photos': '照片',
@@ -860,6 +860,8 @@ export default {
// Group
'group.retry': '重试',
+ 'lazy.load_failed': '无法加载界面的这一部分。自打开此页面以来,hub 可能已更新。',
+ 'lazy.reload': '重新加载页面',
// Sidebar
'sidebar.node': 'Node',
@@ -882,7 +884,7 @@ export default {
'node.service_restarting': '正在重启…',
'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。',
'node.startup_mode_label': '自动启动:',
- 'node.startup_mode_off': '关闭(手动启动)',
+ 'node.startup_mode_off': '仅在 MeshBay 打开时',
'node.startup_mode_signin': '登录时',
'node.startup_mode_service': '作为后台服务(开机时启动)',
'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…',
@@ -1173,4 +1175,32 @@ 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': "保存举报设置",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "已重命名:“{name}”并非在所有系统上都是有效的名称",
+ 'transfers.renamed_n': "为在所有系统上有效而修改的名称:{n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index a59c100..7fd7ab1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -13,6 +13,14 @@ import { HUB } from './hub-client.js';
// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain
// for potential future browser-side use.
+// true / false from a node that has read its roster, null from one that has
+// not yet (it publishes the roster only once it has signed in to the hub) --
+// which is not the same as having no operator.
+export function pairedFrom(result) {
+ const v = result && result.operator_paired;
+ return v === true || v === false ? v : null;
+}
+
function NodeServicePanel({ onChanged, token, username }) {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState('');
@@ -202,7 +210,7 @@ export function NodePage({ groups, token, username }) {
const [editIce, setEditIce] = useState(null);
const [savingIce, setSavingIce] = useState(false);
const [iceInput, setIceInput] = useState('');
- const [operatorPaired, setOperatorPaired] = useState(false);
+ const [operatorPaired, setOperatorPaired] = useState(null);
const [pairBusy, setPairBusy] = useState(false);
const [pairStatus, setPairStatus] = useState('');
const [nodeInfo, setNodeInfo] = useState(null);
@@ -231,7 +239,7 @@ export function NodePage({ groups, token, username }) {
}
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
- setOperatorPaired(!!result.operator_paired);
+ setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
setStatus('connected');
@@ -253,7 +261,7 @@ export function NodePage({ groups, token, username }) {
try {
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
- setOperatorPaired(!!result.operator_paired);
+ setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
} catch {}
@@ -711,7 +719,7 @@ export function NodePage({ groups, token, username }) {
</div>
<${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} />
${actionMsg && html`<div class="node-message">${actionMsg}</div>`}
- ${!operatorPaired && html`
+ ${operatorPaired === false && html`
<div class="node-pair-banner">
<p>${t('node.pair_needed')}</p>
<button class="btn btn-primary btn-small"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
new file mode 100644
index 0000000..bb2438c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
@@ -0,0 +1,51 @@
+/**
+ * A name that can be written on every platform a member saves to
+ * (docs/MESHBAY_DESIGN.md §10).
+ *
+ * A node serves a file under the name its own disk gave it, and a name that is
+ * fine on ext4 can be impossible on Windows or on an exFAT drive: reserved
+ * characters, a trailing dot or space, `CON` or `aux.txt`. The node never
+ * rewrites a name — it is the string that opens the file — so the client does,
+ * at the moment it saves, and says so.
+ *
+ * The same rules as `meshbay_common.paths.sanitize_for_download`, and held to
+ * them by `test_portable_name_parity.py`: two copies of a rule that differ
+ * decide differently which files get renamed.
+ */
+
+const WINDOWS_RESERVED = new Set([
+ 'CON', 'PRN', 'AUX', 'NUL',
+ ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `COM${i}`),
+ ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `LPT${i}`),
+]);
+
+const RESERVED_CHARS = new Set('<>:"/\\|?*');
+
+const reserved = (c) => RESERVED_CHARS.has(c) || c.charCodeAt(0) < 32;
+
+function isPortable(name) {
+ if (!name || name === '.' || name === '..') return false;
+ for (const c of name) if (reserved(c)) return false;
+ if (name.endsWith(' ') || name.endsWith('.')) return false;
+ return !WINDOWS_RESERVED.has(name.split('.', 1)[0].toUpperCase());
+}
+
+/** `name` if it can be written everywhere, otherwise the nearest name that can. */
+export function portableName(name, replacement = '_') {
+ const text = String(name ?? '');
+ if (isPortable(text)) return text;
+ let out = Array.from(text).map((c) => (reserved(c) ? replacement : c)).join('');
+ out = out.replace(/[ .]+$/, '');
+ const dot = out.indexOf('.');
+ let stem = dot === -1 ? out : out.slice(0, dot);
+ const rest = dot === -1 ? '' : out.slice(dot);
+ if (WINDOWS_RESERVED.has(stem.toUpperCase())) stem += replacement;
+ out = stem + rest;
+ return out || 'unnamed';
+}
+
+/** Every segment of a relative path made portable, keeping `/` between them. */
+export function portablePath(path) {
+ return String(path ?? '').split('/')
+ .map((segment) => (segment ? portableName(segment) : segment)).join('/');
+}
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 37775d8..fc91596 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -491,6 +491,14 @@ a:hover { text-decoration: underline; }
margin-bottom: 24px;
}
+/* lazy.js: a view whose code could not be fetched says so, in place of the
+ spinner it would otherwise have kept for good. Inside the player's overlay
+ the backdrop is near-black whatever the theme, so the text colour cannot
+ come from the theme there. */
+.lazy-failed { text-align: center; max-width: 520px; margin: 24px auto; }
+.lazy-failed p { margin-bottom: 12px; }
+.video-overlay .lazy-failed { color: #e2e8f0; }
+
/* ── Page center (login / register) ───────────────────────────────────────── */
.page-center {
@@ -682,7 +690,7 @@ a:hover { text-decoration: underline; }
overflow: hidden;
pointer-events: none;
background: linear-gradient(155deg,
- #86a3c4 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%);
+ #6d87a5 0%, #5d738c 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%);
}
/* The night-blue pool. */
.welcome-backdrop::before {
@@ -1979,6 +1987,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
justify-content: center;
}
+/* The player opens on top of a show's detail modal, which stays open under it
+ so the next episode is one click away when the player closes. Both are
+ `.video-overlay`; equal values would leave the order to where each happens
+ to sit in the DOM, which is not a decision. Below the operator's match
+ search (210), which only ever opens from the modal, never over the player. */
+.video-overlay.video-player-overlay { z-index: 205; }
+
.video-top-bar {
position: absolute;
top: 0;
@@ -2573,6 +2588,7 @@ a.transfer-name {
margin-top: 3px;
}
.transfer-failed { color: var(--error); }
+.transfer-note { justify-content: flex-start; font-style: italic; }
/* ── File selection ──────────────────────────────────────────────────────── */
@@ -4135,6 +4151,13 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
margin-bottom: 4px;
}
.video-episode-row:hover { border-color: var(--accent); color: var(--accent); }
+/* The episode last started from this modal, so the one to watch next is the
+ row under it. A border and a tint, not a colour change of the text alone,
+ so it does not read as the hover state left behind. */
+.video-episode-row.last-played {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
.video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; }
.video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
.video-episode-thumb {
@@ -5329,7 +5352,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);
@@ -5338,7 +5363,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/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
index 9a1455a..0d012ed 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
@@ -29,13 +29,6 @@ function _live(status) {
|| status === 'paused';
}
-/** Raised by `run` when it stopped because the transfer was paused. */
-function _pausedError() {
- const err = new Error('Paused');
- err.name = 'PausedError';
- return err;
-}
-
function _abortError() {
const err = new Error('Cancelled');
err.name = 'AbortError';
@@ -80,6 +73,7 @@ export class TransferStore {
queuedByOwnLimit: Boolean(
it.lease && it.lease.cap && it.lease.used >= it.lease.cap),
error: it.error || '',
+ note: it.note || '',
speed: this._speed(it),
// The ETA is drawn only once the window holds a few seconds of real
// measurement -- see etaSeconds.
@@ -141,10 +135,14 @@ export class TransferStore {
* there is somewhere to write — see file-utils.js's downloadEntry.
*/
start({ kind, name, total = 0, transport = null, run, open = null,
- lease = null, prepare = null, makeLease = null, pausable = false }) {
+ lease = null, prepare = null, makeLease = null, pausable = false,
+ note = '' }) {
const item = {
id: _nextId++,
kind, name, total, transport, open, lease,
+ // One line said under the name for the life of the row — that a name was
+ // changed to be written here, for instance.
+ note,
done: 0,
// A transfer that has to wait for a slot starts as 'queued', not
// 'running'. Two different things are true of it — nothing is moving, and
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
index 03a0f33..c7c3f47 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -263,7 +263,10 @@ extendTransport(class {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_add', path, signFn);
+ // Everything the node will act on is in the subject, `writable` included.
+ const subject = window.MeshBayCrypto.rootAddSubject(
+ path, name || '', kind || 'generic', !!writable, !!removable);
+ return this._authorizeAdminOp(msg, 'root_add', subject, signFn);
}
return msg;
}
@@ -369,11 +372,13 @@ extendTransport(class {
async attachGroup(name, sharedDir, uploadDir, signFn) {
const msg = await this._sendAndWait({
type: 'group_attach', v: '0.1',
- name, shared_dir: sharedDir, upload_dir: uploadDir || '',
+ name, shared_dir: sharedDir, upload_dir: uploadDir || '', writable: true,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'group_attach', name, signFn);
+ // The directory being exposed is signed, not only the group's name.
+ const subject = window.MeshBayCrypto.groupAttachSubject(name, sharedDir, true);
+ return this._authorizeAdminOp(msg, 'group_attach', subject, signFn);
}
return msg;
}
@@ -416,7 +421,10 @@ extendTransport(class {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
+ // The node keeps the first 64 code points of the name, as Python slices.
+ const name = Array.from(username || '').slice(0, 64).join('');
+ const subject = window.MeshBayCrypto.inviteCreateSubject(userId, name);
+ return this._authorizeAdminOp(msg, 'invite_create', subject, signFn);
}
return msg;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
index d6d733f..270c76e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
@@ -212,7 +212,6 @@ extendTransport(class {
if (msg.epoch) this.chatEpoch = msg.epoch;
this._chatKeys = null;
this._chatKeysInFlight = null;
- if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch);
}
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
index 4291cf8..199b6a2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
@@ -268,6 +268,10 @@ extendTransport(class {
* The counterpart of storeKeypairBundle: turning the setting off has to remove
* what is already stored, not merely stop adding to it — otherwise the blob
* stays on every node the account has ever joined (C4).
+ *
+ * Nothing calls this yet, on purpose: it is reserved for `device_policy`
+ * (docs/MESHBAY_DESIGN.md §3.7, O3). Offered alone, it would strand the next
+ * browser that signs in to this node.
*/
async deleteKeypairBundle() {
const msg = await this._sendAndWait({
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
index f94eb40..cf53c08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
@@ -106,18 +106,17 @@ extendTransport(class {
* `language`, to leave whatever is stored unchanged.
*/
async setTmdbConfig(token, language, signFn) {
+ const tok = token === undefined ? null : token;
+ const lang = language === undefined ? null : language;
const msg = await this._sendAndWait({
- type: 'tmdb_config', v: '0.7',
- token: token === undefined ? null : token,
- language: language === undefined ? null : language,
+ type: 'tmdb_config', v: '0.7', token: tok, language: lang,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- // Must match the node's subject byte-for-byte (apps/video_meta.py
- // _do_tmdb_config) — the token itself is never part of the subject
- // (it would end up in the audit log in plaintext), only whether one
- // was supplied. The language is not a secret, so it appears as-is.
- const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`;
+ // Must match the node's subject byte for byte (apps/video_meta.py
+ // _do_tmdb_config). The token is named by its SHA-256, never written:
+ // the subject ends up in the audit log.
+ const subject = await window.MeshBayCrypto.tmdbConfigSubject(tok, lang);
return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn);
}
return msg;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
index 6ae51d1..18ad9d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -110,7 +110,7 @@ async function rewrapAllNodes(o) {
try {
await _acWithTimeout(
tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
- o.username, o.userId, null, recoveryKey),
+ o.username, o.userId, null, recoveryKey, undefined, n.pk_node),
30000, 'connect');
if (tp.newNodeBundle) {
// No identity existed on this node — connect just minted one under
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index f3683eb..98d010e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -305,8 +305,10 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '4.0';
-// Raised with it: 4.0 is a flag day. A member now presents a short-lived
+const MNP_V = '5.0';
+// Not raised with 5.0 (see meshbay_common/__init__.py): the break is confined to
+// four signed operations, which a peer on the other side of it refuses to sign.
+// Set at 4.0, a flag day. A member now presents a short-lived
// MNP-audience token in the handshake, not its hub session token — a node
// older than 4.0 expected the session token, and one newer refuses it, so the
// two cannot authenticate across the break. This is the C6 rule: no
@@ -498,11 +500,6 @@ class MeshBayTransport {
this._inFlightUploads = new Set();
// tr → Lease. A transfer's slot on the node, from the client's side.
this._leases = new Map();
- // Set from the handshake ack: a node that answers with `transfer_limits`
- // speaks transfer slots. Used instead of a timeout, because "no answer
- // yet" and "this node will never answer" are indistinguishable in time and
- // guessing wrong either stalls every download or defeats the cap.
- this._transferLimits = null;
// Set once close() runs — stops the automatic reconnect from firing on a
// connection the caller tore down on purpose (leaving the group, page
// unload), which would otherwise race back in right as everything else
@@ -571,18 +568,11 @@ class MeshBayTransport {
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onRootsChanged(fn) { this._onRootsChanged = fn; }
- /** The MNP version the connected node declared, or '' before a handshake. */
- get nodeVersion() { return this._nodeVersion || ''; }
-
- /** This member's own caps in this group, or null when the node said nothing. */
- get transferLimits() { return this._transferLimits; }
-
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
set onAppDirectories(fn) { this._onAppDirectories = fn; }
set onChatDirectory(fn) { this._onChatDirectory = fn; }
set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; }
set onSearchListed(fn) { this._onSearchListed = fn; }
- set onChatEpoch(fn) { this._onChatEpoch = fn; }
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; }
@@ -663,21 +653,21 @@ class MeshBayTransport {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this._accessToken}`,
},
- body: JSON.stringify({}),
+ body: JSON.stringify({ node_pk: this._nodePkTarget || '' }),
});
if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`);
return (await r.json()).mnp_token;
}
async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
- userId, joinCode, recoveryKey, joinNodePk) {
+ userId, joinCode, recoveryKey, joinNodePk, nodePk) {
// Remembered for _reconnectLoop, which calls connect() again with these
// same values (plus a freshly-fetched token and the identity connect()
// itself settles on below) after the WebRTC connection is declared
// "failed" — see the pc.onconnectionstatechange handler further down.
this._connectArgs = {
nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey,
- joinNodePk,
+ joinNodePk, nodePk,
};
this._lastToken = jwtToken;
// The constructor sets this once from whatever token the caller had at
@@ -695,6 +685,10 @@ class MeshBayTransport {
this._recoveryKey = recoveryKey || null;
this._username = username || null;
this._userId = userId || null;
+ // The key of the node we mean to reach, from the hub's node list. The MNP
+ // token is bound to it (E10) so it cannot be replayed to another node. It is
+ // the *expected* key; `this.nodePk` below is the one the node then proves.
+ this._nodePkTarget = nodePk || '';
// The group this connection is for. Kept on the instance because the
// handshake is not the only thing that needs it any more: device_hello and
// the chat envelope both bind to it, and both run outside connect()'s scope.
@@ -937,12 +931,9 @@ class MeshBayTransport {
if (reply.type === 'handshake_challenge') {
// The node's half of the range. Checked before anything else in this
// block, because everything below — the join, the proof, the sealed ack
- // — assumes both sides mean the same thing by each message.
+ // — assumes both sides mean the same thing by each message. Nothing else
+ // reads the node's version: a peer this admits speaks every message here.
_checkNodeVersion(reply);
- // Kept for diagnostics only. Nothing branches on it: the range check
- // above is what decides whether these two can talk at all, and a peer it
- // admits speaks every message in this file.
- this._nodeVersion = String(reply.v || '');
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -955,16 +946,14 @@ class MeshBayTransport {
// nonce_node ties a join to this connection, so one cannot be lifted onto
// another. node_pk is announced here because a first-time member has no
// GEK and so cannot complete the handshake that would prove it. From an
- // older node it is unverified until the ack below checks it.
+ // The signature checked next is what proves it here, before the ack.
this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce);
this.nodePk = reply.node_pk || null;
- // Since MNP 3.4 the node signs its challenge over this connection, so
- // node_pk is proved here and not only at the ack — which comes after any
- // join. A signature that does not verify is a peer lying about which node
- // it is, and is refused. An absent one is an older node: `nodePkProved`
- // stays false, and whatever needs the key proved before a code leaves
- // (an invitation link names its node) reads that — never a version.
- this.nodePkProved = await _challengeProvesNodeKey(
+ // The node signs its challenge over this connection, so node_pk is proved
+ // here and not only at the ack — which comes after any join. Every node
+ // this client can reach signs (the floor is 4.0, and signing is 3.4), so
+ // a missing signature is refused exactly like a wrong one.
+ await _challengeProvesNodeKey(
reply, groupId || '', this._nonceClient,
this._pc.localDescription.sdp, this._rawAnswerSdp);
@@ -1060,8 +1049,7 @@ class MeshBayTransport {
// (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not
// even a join without the code, which this node would answer by asking
// for one.
- const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk,
- this.nodePkProved);
+ const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk);
if (linkRefusal) {
this._joinError = linkRefusal;
} else if (!gekRaw && this._sessionKeys && userId) {
@@ -1161,7 +1149,6 @@ class MeshBayTransport {
delete ack.nonce;
delete ack.ct;
Object.assign(ack, config);
- this._transferLimits = ack.transfer_limits || null;
// From the *sealed* part of the ack: a forged epoch would have this
// client sealing under a key the group has retired.
@@ -1321,7 +1308,7 @@ class MeshBayTransport {
ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw,
this._sessionKeys, args.bundleKey, args.username,
args.userId, args.joinCode, undefined,
- args.joinNodePk);
+ args.joinNodePk, args.nodePk);
} finally {
this._inReconnectAttempt = false;
}
@@ -2194,35 +2181,29 @@ class MeshBayTransport {
* Why a code from an invitation link must not go to this node, or null.
*
* `link_other_node` is the caller's cue to try the next node the hub listed,
- * as for `not_hosted`: the link names one node, and this is not it. An older
- * node that cannot prove its key early is refused rather than trusted — it
- * cannot have issued a link code anyway.
+ * as for `not_hosted`: the link names one node, and this is not it. `nodePk`
+ * has already been proved by the challenge signature, which is required.
*/
-function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) {
+function _linkJoinRefusal(joinNodePk, joinCode, nodePk) {
if (!joinNodePk || !joinCode) return null;
if (nodePk !== joinNodePk) {
const err = new Error('This invitation was issued by another machine hosting this group.');
err.reason = 'link_other_node';
return err;
}
- if (!nodePkProved) {
- const err = new Error('This node is too old to accept invitation links.');
- err.reason = 'link_node_unproved';
- return err;
- }
return null;
}
/**
- * Whether `handshake_challenge` proves the key it announces (MNP 3.4).
+ * Check that `handshake_challenge` proves the key it announces; throw if not.
*
- * True when it carries a signature that verifies over this connection, false
- * when it carries none — an older node, which proves its key only at the ack.
- * A signature that does not verify is a peer lying about which node it is, and
- * throws: that is a refusal, not a node that merely cannot say.
+ * A node signs whenever it has a channel binding, and one without a binding
+ * could not complete the handshake anyway (its proof is refused), so a missing
+ * signature is refused like a wrong one: both are a peer that cannot show it is
+ * the node it names. There is no "older node" case — the floor is 4.0.
*/
async function _challengeProvesNodeKey(reply, groupId, nonceClient, offerSdp, answerSdp) {
- if (!reply.sig) return false;
+ if (!reply.sig) throw new Error('Node challenge is not signed — refusing connection');
const C = window.MeshBayCrypto;
let ok = false;
try {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 124033e..9e2db1c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -619,6 +619,13 @@ function VideoDetailModal({
const [selectedSeason, setSelectedSeason] = useState(null);
useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]);
+ // A show's modal stays open under the player (PosterGrid's onPlay), so
+ // closing the player lands back here. Marking the episode just started is
+ // what makes "the next one" the row under the mark, rather than a row the
+ // reader has to find again by its number.
+ const [lastPlayedId, setLastPlayedId] = useState(null);
+ useEffect(() => { setLastPlayedId(null); }, [show]);
+
const showMultiSeason = Boolean(show && show.seasons.length > 1);
const seasonMeta = useSeasonMeta(
transportRef, confident ? meta.tmdb_id : null, selectedSeason,
@@ -708,7 +715,10 @@ function VideoDetailModal({
</div>
`}
${s.episodes.map((ep) => html`
- <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
+ <button key=${ep.id}
+ class="video-episode-row ${ep.id === lastPlayedId ? 'last-played' : ''}"
+ aria-current=${ep.id === lastPlayedId ? 'true' : undefined}
+ onClick=${() => { setLastPlayedId(ep.id); onPlay(ep); }}>
<${LazyTile} cls="video-episode-thumb-slot">
<${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name}
cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef}
@@ -857,13 +867,18 @@ function PosterGrid({
</${LazyTile}>
`; })}
</div>
+ ${/* A show's modal is left open under the player: watching episode after
+ episode used to mean reopening the show and picking the season again
+ every time. A film has nothing left to pick, so its modal still
+ closes. The player stacks above it by its own z-index (style.css,
+ .video-player-overlay), not by DOM order. */''}
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
repEntry=${detail.repEntry} show=${detail.show}
transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin}
tmdbEnabled=${tmdbEnabled}
onClose=${() => setDetail(null)}
- onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
+ onPlay=${(entry) => { if (!detail.show) setDetail(null); onPreview(entry); }} />
`}
`;
}
@@ -976,6 +991,22 @@ function VideoApp({
useEffect(() => { setMode(loadViewMode()); }, [groupId]);
useEffect(() => { setFilter(''); setTypeFilter('all'); }, [groupId]);
+ // When the operator changes the node's TMDB language, the node drops its
+ // metadata cache and refetches in the new language, and — until a language
+ // is set — answers nothing at all rather than querying in English (§9.7).
+ // Tell every mounted tile to redo its media_meta_req and drop the
+ // show-level meta already merged here, so the grid switches language (or
+ // fills in for the first time, right after the operator picks one) without
+ // a page reload. Skip the initial mount: the language is already right then,
+ // and bumping would restorm TMDB on every open of the Videos tab.
+ const tmdbLanguage = tmdbConfig ? (tmdbConfig.language || '') : '';
+ const firstLangRef = useRef(true);
+ useEffect(() => {
+ if (firstLangRef.current) { firstLangRef.current = false; return; }
+ setMetaByGroup({});
+ bumpMediaMetaGeneration();
+ }, [tmdbLanguage]);
+
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
const videoEntries = availableEntries || entries;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 6eaacc5..fa10d15 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -270,12 +270,6 @@ function reconnectPlan(playhead, range, ended, castActive) {
return { mode: 'seek', at: playhead };
}
-function _mseSupported(codec) {
- if (!window.MediaSource) return false;
- const mime = `video/mp4; codecs="${codec}"`;
- return MediaSource.isTypeSupported(mime);
-}
-
/** Seconds as h:mm:ss, or m:ss under an hour. */
function formatClock(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
@@ -1596,7 +1590,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}, []);
return html`
- <div class="video-overlay" onClick=${(e) => {
+ <div class="video-overlay video-player-overlay" onClick=${(e) => {
if (e.target.classList.contains('video-overlay')) onClose();
}}>
<div class="video-top-bar">