aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py50
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py33
5 files changed, 125 insertions, 21 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 7ee05ca..4141c79 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -18,6 +18,7 @@ from meshbay_hub.api.deps import require_admin, require_moderator
from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
+from meshbay_hub import hub_settings
log = logging.getLogger(__name__)
@@ -35,6 +36,55 @@ class GroupPatchRequest(BaseModel):
status: str | None = None
+class SettingsPatchRequest(BaseModel):
+ allow_public_groups: bool | None = None
+
+
+# ── Instance settings ────────────────────────────────────────────────────────
+
+def _settings_payload(allow_public_groups: bool) -> dict:
+ return {"allow_public_groups": allow_public_groups}
+
+
+@router.get("/settings")
+async def admin_get_settings(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+):
+ """Instance-wide policy an admin controls from the panel. Moderators may read."""
+ return _settings_payload(await hub_settings.public_groups_allowed(db))
+
+
+@router.patch("/settings")
+async def admin_patch_settings(
+ body: SettingsPatchRequest,
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Change instance policy. Admin only — moderators get the read above.
+
+ The enforcement lives where the thing being restricted happens (public-group
+ creation is refused in `groups.create_group`), so flipping this here is the
+ whole change: a client that keeps drawing the option still cannot use it.
+ """
+ if body.allow_public_groups is not None:
+ await hub_settings.set_raw(
+ db, hub_settings.ALLOW_PUBLIC_GROUPS,
+ "true" if body.allow_public_groups else "false")
+ log.info("Instance setting allow_public_groups=%s by %s",
+ body.allow_public_groups, current_user.username)
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_settings_update",
+ ip_address="admin",
+ detail=f"allow_public_groups={body.allow_public_groups}",
+ ))
+ await db.commit()
+
+ return _settings_payload(await hub_settings.public_groups_allowed(db))
+
+
# ── Stats ────────────────────────────────────────────────────────────────────
@router.get("/stats")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
index b6b4acf..7f1262d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
@@ -31,7 +31,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_common import MHP_VERSION
-from meshbay_hub import __version__
+from meshbay_hub import __version__, hub_settings
from meshbay_hub.api.deps import require_admin
from meshbay_hub.auth import _hub_id, _hub_sk_pem, hub_public_key_pem
from meshbay_hub.db.engine import get_db
@@ -104,9 +104,15 @@ async def export_directory(
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
- result = await db.execute(
- select(Group).where(Group.visibility == "public", Group.status == "active"))
- groups = result.scalars().all()
+ # A hub with public groups switched off advertises nothing to its peers —
+ # the local directory is empty (groups.list_public_groups), and the exported
+ # one has to match or peers keep showing groups this hub no longer serves.
+ if not await hub_settings.public_groups_allowed(db):
+ groups = []
+ else:
+ result = await db.execute(
+ select(Group).where(Group.visibility == "public", Group.status == "active"))
+ groups = result.scalars().all()
return {
"hub_id": _hub_id,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index e78d950..91aa9c4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
+from meshbay_hub import hub_settings
from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
@@ -107,7 +108,17 @@ async def group_online_nodes(
if not group:
raise HTTPException(status_code=404, detail="Group not found")
if group.status != "active":
- raise HTTPException(status_code=403, detail="Group is suspended")
+ # Name the real state: "suspended" is reversible, "revoked" is a signed
+ # instruction every node enforces. The client shows this string as-is.
+ raise HTTPException(status_code=403, detail=f"Group is {group.status}")
+
+ # A public group is normally readable by anyone — that is the point of it.
+ # But when the hub has public groups switched off, an existing one keeps
+ # working only for the people already in it: no non-member gets handed a
+ # node to connect to. Members always have a row here, so they are unaffected.
+ if group.visibility == "public" and not await hub_settings.public_groups_allowed(db):
+ if not await db.get(GroupMember, (group_id, current_user.id)):
+ raise HTTPException(status_code=403, detail="Not a member of this group")
node_ids = get_online_nodes_for_group(group_id)
nodes = []
@@ -127,6 +138,12 @@ async def list_public_groups(
include_federated: bool = True,
):
"""List/search public groups — local and optionally federated. No auth required."""
+ # The hub admin can switch public groups off for the whole instance. When
+ # they have, there is no directory at all — not the local groups that predate
+ # the switch, and not the federated ones a peer still advertises. The switch
+ # is read live, so flipping it back brings the directory straight back.
+ if not await hub_settings.public_groups_allowed(db):
+ return {"groups": [], "total": 0}
# Unhosted groups are absent from the directory: until a node announces it,
# a group has no files, no key and nothing to connect to, so listing it only
# produces a dead end. Its owner still sees it in /mine while they set it up.
@@ -272,9 +289,14 @@ async def join_group(
if not group:
raise HTTPException(status_code=404, detail="Group not found")
if group.status != "active":
- raise HTTPException(status_code=403, detail="Group is not active")
+ raise HTTPException(status_code=403, detail=f"Group is {group.status}")
if group.join_policy != "open":
raise HTTPException(status_code=403, detail="Group does not allow open joining")
+ if group.visibility == "public" and not await hub_settings.public_groups_allowed(db):
+ # The group predates the switch; open joining is off with it. Existing
+ # members keep their row and their access.
+ raise HTTPException(
+ status_code=403, detail="This hub does not allow joining public groups.")
existing = await db.get(GroupMember, (group_id, current_user.id))
if existing:
@@ -365,6 +387,14 @@ async def create_group(
detail="A private group is invite-only. Make it public if you want "
"anyone to be able to join.")
if body.visibility == "public":
+ if not await hub_settings.public_groups_allowed(db):
+ # Instance policy, set by a hub admin in the panel. Absolute — staff
+ # included — because the way back is to re-enable it, not to slip
+ # past it. A client that still shows the "open" choice lands here.
+ raise HTTPException(
+ status_code=403,
+ detail="This hub does not allow public groups. Create the group "
+ "as private — you can still invite people to it.")
await _check_public_group_quota(db, current_user)
desc = (body.description or "")[:512] if body.description else None
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 4aef93d..8692995 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -1,22 +1,29 @@
"""Hub info endpoints — /v1/hub/*"""
-from fastapi import APIRouter
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
from meshbay_common import MNP_VERSION, MHP_VERSION
-from meshbay_hub import __version__
+from meshbay_hub import __version__, hub_settings
from meshbay_hub.auth import hub_public_key_pem
-from meshbay_hub.db.engine import get_engine
+from meshbay_hub.db.engine import get_db, get_engine
router = APIRouter(prefix="/v1/hub", tags=["hub"])
@router.get("/info")
-async def hub_info():
+async def hub_info(db: AsyncSession = Depends(get_db)):
engine = get_engine()
return {
"hub_version": __version__,
"mnp_version": MNP_VERSION,
"mhp_version": MHP_VERSION,
"db_dialect": engine.dialect.name,
+ # Instance policy the SPA needs before drawing the create-group form.
+ # Unauthenticated on purpose: it is not a secret, and the form is
+ # reachable before the group list loads. The hub enforces it regardless
+ # of what any client does with this flag.
+ "allow_public_groups": await hub_settings.public_groups_allowed(db),
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index 84c1167..a8feae8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -22,6 +22,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from meshbay_hub import hub_settings
from meshbay_hub.api.deps import get_current_user
from meshbay_hub.api.middleware import limiter
from meshbay_hub.db.engine import get_db
@@ -93,6 +94,11 @@ async def webrtc_offer(
# 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, []))
if node_group_ids:
result = await db.execute(
@@ -102,19 +108,24 @@ async def webrtc_offer(
))
shared = [gid for (gid,) in result.all()]
if not shared:
- has_open = await db.execute(
- select(Group.id).where(
- Group.id.in_(node_group_ids),
- Group.join_policy == "open",
- Group.status == "active",
- ))
- if not has_open.first():
+ 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:
- active = await db.execute(
- select(Group.id).where(Group.id.in_(shared), Group.status == "active"))
- if not active.first():
- raise HTTPException(status_code=403, detail="Group is not active")
+ 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}")
if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER:
raise HTTPException(status_code=429, detail="Too many pending connections")