aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-19 17:58:05 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-19 17:58:05 +0200
commit1ec316035ce9aa656dd18a05889856bce9e3aba3 (patch)
treeb50e97f50d485ef2a88ce36fe4d7511d9fe3e288 /packages/meshbay-hub/src
parent171a3e175889ce30f201fcdf5c4632f480344ae6 (diff)
parent95dd3dc13aecec85c2fd72410cd4d1f4ed582dfa (diff)
downloadmeshbay-1ec316035ce9aa656dd18a05889856bce9e3aba3.tar.gz
Merge origin/main: the tree-wide ruff pass beside the Music reconnect work
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/notifications.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/relay.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py42
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/csam.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/__init__.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5f6a7b8c9d0_add_mail_quota.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/login_throttle.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py13
30 files changed, 204 insertions, 176 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 7ca1e68..b4b2f4f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -6,19 +6,18 @@ Separate from moderation.py (which handles public reporting and content blocklis
"""
import logging
-from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.auth import decrypt_email
+from meshbay_hub import hub_settings
from meshbay_hub.api.deps import require_admin, require_moderator, user_is_admin
from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected
+from meshbay_hub.auth import decrypt_email
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__)
@@ -318,7 +317,8 @@ async def admin_patch_user(
if body.role not in ("user", "moderator", "admin"):
raise HTTPException(status_code=422, detail="role must be user, moderator, or admin")
user.role = body.role
- log.info("User %s role changed to %s by %s", user.username, body.role, current_user.username)
+ log.info("User %s role changed to %s by %s",
+ user.username, body.role, current_user.username)
await create_notification(
db, user.id, "role_change",
f"Your role has been changed to {body.role}",
@@ -326,7 +326,9 @@ async def admin_patch_user(
if body.status is not None:
if body.status not in ("active", "suspended", "revoked"):
- raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked")
+ raise HTTPException(
+ status_code=422,
+ detail="status must be active, suspended, or revoked")
user.status = body.status
log.info("User %s status changed to %s by %s",
user.username, body.status, current_user.username)
@@ -484,7 +486,9 @@ async def admin_patch_group(
if body.status is not None:
if body.status not in ("active", "suspended", "revoked"):
- raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked")
+ raise HTTPException(
+ status_code=422,
+ detail="status must be active, suspended, or revoked")
group.status = body.status
log.info("Group %s status changed to %s by %s",
group.name, body.status, current_user.username)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 907a481..501be9d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -8,8 +8,8 @@ JWT scope enforcement:
"""
from fastapi import Depends, Header, HTTPException, status
-from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import decode_access_token
from meshbay_hub.db.engine import get_db
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
index 9e252c6..755639e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
@@ -23,18 +23,18 @@ Protocol version: MHP 0.1
import logging
import time
import uuid
+from datetime import UTC
import jwt
-from fastapi import APIRouter, Depends, HTTPException, Header
+from fastapi import APIRouter, Depends, Header, HTTPException
+from meshbay_common import MHP_VERSION
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_common import MHP_VERSION
from meshbay_hub import __version__, hub_settings
from meshbay_hub.api.deps import require_admin
-from meshbay_hub.auth import (
- hub_id, hub_private_key_pem, hub_public_key_pem)
+from meshbay_hub.auth import hub_id, hub_private_key_pem, hub_public_key_pem
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import FederatedGroup, Group, HubPeer, User
@@ -244,8 +244,8 @@ async def receive_directory(
if len(body.groups) > MAX_FEDERATED_GROUPS_PER_PUSH:
raise HTTPException(status_code=413, detail="Too many groups in one push")
- from datetime import datetime, timezone
- now = datetime.now(timezone.utc)
+ from datetime import datetime
+ now = datetime.now(UTC)
have = await db.scalar(
select(func.count()).select_from(FederatedGroup)
.where(FederatedGroup.source_hub == sender)) or 0
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 72ba194..3a11345 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -1,22 +1,27 @@
"""Group endpoints — /v1/groups/*"""
import re
+from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
-from datetime import datetime, timezone
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import hub_settings, mail
-from meshbay_hub.auth import decrypt_email
from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
+from meshbay_hub.auth import decrypt_email
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
- FederatedGroup, Group, GroupMember, IPLog, SwarmSource, User,
+ FederatedGroup,
+ Group,
+ GroupMember,
+ IPLog,
+ SwarmSource,
+ User,
)
router = APIRouter(prefix="/v1/groups", tags=["groups"])
@@ -97,7 +102,7 @@ async def touch_group_activity(
await db.execute(
update(Group)
.where(Group.id == group_id)
- .values(last_activity_at=datetime.now(timezone.utc)))
+ .values(last_activity_at=datetime.now(UTC)))
await db.commit()
return {"ok": True}
@@ -261,9 +266,9 @@ async def swarm_register(
detail="endpoint must be '<webrtc|quic>:<port>' — a port on the "
"registering node, not an address")
- from datetime import datetime, timezone
+ from datetime import datetime
existing = await db.get(SwarmSource, (body.content_hash, current_user.id))
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
if existing:
existing.endpoint = body.endpoint
existing.last_seen = now
@@ -297,8 +302,8 @@ async def swarm_sources(
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, timezone, timedelta
- cutoff = datetime.now(timezone.utc) - timedelta(minutes=30)
+ from datetime import datetime, timedelta
+ cutoff = datetime.now(UTC) - timedelta(minutes=30)
result = await db.execute(
select(SwarmSource)
.where(
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 94e9b3c..cab60c8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -1,9 +1,9 @@
"""Hub info endpoints — /v1/hub/*"""
from fastapi import APIRouter, Depends
+from meshbay_common import MHP_VERSION, MNP_VERSION
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_common import MNP_VERSION, MHP_VERSION
from meshbay_hub import __version__, hub_settings
from meshbay_hub.api import federation
from meshbay_hub.auth import hub_public_key_pem
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
index ee10cbc..35c688c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -23,7 +23,6 @@ Node integration:
"""
import logging
-from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 83b60f2..7478173 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -1,20 +1,20 @@
"""Node endpoints — /v1/nodes/*"""
-from datetime import datetime, timezone
import base64
import time
+from datetime import UTC, datetime
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.auth import issue_access_token
from meshbay_hub.api.deps import get_current_user
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
+from meshbay_hub.auth import issue_access_token
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, Node, User
@@ -156,7 +156,7 @@ async def announce_node(
if node is not None:
node.endpoint_hint = body.endpoint_hint
node.observed_ip = seen_from
- node.last_seen = datetime.now(timezone.utc)
+ node.last_seen = datetime.now(UTC)
db.add(IPLog(user_id=current_user.id, event="node_announce",
ip_address=seen_from, detail=body.endpoint_hint))
await db.commit()
@@ -182,7 +182,7 @@ async def announce_node(
pk_node=body.pk_node,
endpoint_hint=body.endpoint_hint,
observed_ip=seen_from,
- last_seen=datetime.now(timezone.utc),
+ last_seen=datetime.now(UTC),
)
db.add(node)
db.add(IPLog(
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
index b5783ab..d96ec18 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
@@ -19,9 +19,9 @@ migration for no gain, and `unread_only` stays because it is what an older
interface asks for and it still answers correctly — every row is unread.
"""
-from fastapi import APIRouter, Depends, HTTPException, Query
-from datetime import datetime, timezone
+from datetime import UTC, datetime
+from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -182,7 +182,7 @@ async def create_notification(
existing.detail = detail
existing.link = link
existing.read = False
- existing.created_at = datetime.now(timezone.utc)
+ existing.created_at = datetime.now(UTC)
await db.flush()
return existing
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
index 08d935b..7bb3f66 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/relay.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
@@ -102,7 +102,8 @@ async def relay_register(
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 hub admin to run POST /v1/relays/approve")
+ 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(
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 1f1f5c5..f8cae8a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -26,23 +26,21 @@ and close active connections for that user.
"""
import asyncio
-import base64
import json
import logging
import time
import uuid
-from datetime import datetime, timezone
-from typing import Any
+from datetime import UTC, datetime
+import jwt
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
+from meshbay_common.background import spawn
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
-import jwt
-from meshbay_common.background import spawn
-from meshbay_hub.auth import hub_public_key_pem, decode_access_token
from meshbay_hub.api.deps import get_current_user, require_admin
+from meshbay_hub.auth import decode_access_token
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
@@ -143,7 +141,7 @@ async def _mark_hosted(group_ids: list[str]) -> None:
await db.execute(
update(Group)
.where(Group.id.in_(group_ids), Group.hosted_at.is_(None))
- .values(hosted_at=datetime.now(timezone.utc)))
+ .values(hosted_at=datetime.now(UTC)))
await db.commit()
except Exception as e:
# A group that stays unhosted in the table is visible to its owner and
@@ -169,7 +167,7 @@ async def broadcast_revocation(token: str) -> int:
def _sign_revocation(target: str, target_id: str, reason: str) -> str:
"""Issue a signed revocation token (JWT EdDSA)."""
- from meshbay_hub.auth import _hub_sk_pem, _hub_id
+ from meshbay_hub.auth import _hub_id, _hub_sk_pem
now = int(time.time())
payload = {
"type": "revocation",
@@ -208,9 +206,9 @@ async def _handle_chat_notify(group_id: str, sender_name: str, sender_user_id: s
(node_id or "?")[:8])
return
try:
- from meshbay_hub.db.engine import get_session_factory
- from meshbay_hub.db.models import GroupMember, Group
from meshbay_hub.api.notifications import create_notification
+ from meshbay_hub.db.engine import get_session_factory
+ from meshbay_hub.db.models import Group, GroupMember
async with get_session_factory()() as db:
group = await db.get(Group, group_id)
@@ -478,7 +476,7 @@ async def notify_incoming(
try:
await asyncio.wait_for(event.wait(), timeout=5.0)
- except asyncio.TimeoutError:
+ except TimeoutError:
raise HTTPException(status_code=504, detail="Node did not respond in time")
finally:
_punch_events.pop(node_id, None)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index bc03b45..8a10822 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -25,8 +25,8 @@ 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
from meshbay_hub.api.netutil import client_ip
+from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupMember, IPLog, User
log = logging.getLogger(__name__)
@@ -167,7 +167,7 @@ async def webrtc_offer(
try:
answer = await asyncio.wait_for(answer_future, timeout=15.0)
- except asyncio.TimeoutError:
+ except TimeoutError:
raise HTTPException(
status_code=504, detail="Node did not respond with WebRTC answer")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 0394f53..2666e86 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -6,7 +6,7 @@ import re
import secrets
import time
import uuid
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -33,8 +33,17 @@ from meshbay_hub.auth import (
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
- EmailVerification, Group, GroupMember, IPLog, Node, Notification,
- RefreshToken, SwarmSource, User, UserDevice, UserPreference,
+ EmailVerification,
+ Group,
+ GroupMember,
+ IPLog,
+ Node,
+ Notification,
+ RefreshToken,
+ SwarmSource,
+ User,
+ UserDevice,
+ UserPreference,
)
log = logging.getLogger(__name__)
@@ -61,7 +70,7 @@ async def _refresh_expiry(db: AsyncSession, family_id: str | None = None) -> dat
renewals of a tab that is being used.
"""
limits = await hub_settings.session_limits(db)
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
idle = max(limits["refresh_idle_hours"] * 3600, _ttl() + 3600)
started = now
if family_id is not None:
@@ -69,7 +78,7 @@ async def _refresh_expiry(db: AsyncSession, family_id: str | None = None) -> dat
select(func.min(RefreshToken.created_at))
.where(RefreshToken.family_id == family_id))
if first is not None:
- started = first if first.tzinfo else first.replace(tzinfo=timezone.utc)
+ started = first if first.tzinfo else first.replace(tzinfo=UTC)
return min(now + timedelta(seconds=idle),
started + timedelta(hours=limits["max_hours"]))
@@ -193,7 +202,7 @@ async def register(
EmailVerification.user_id == found.id,
EmailVerification.purpose == "registration",
EmailVerification.created_at
- > datetime.now(timezone.utc)
+ > datetime.now(UTC)
- timedelta(seconds=resend_cooldown),
))
if not recent.first():
@@ -270,7 +279,7 @@ async def _create_and_send_verification(
code=code,
purpose="registration",
user_id=user.id,
- expires_at=datetime.now(timezone.utc) + timedelta(seconds=VERIFICATION_TTL),
+ expires_at=datetime.now(UTC) + timedelta(seconds=VERIFICATION_TTL),
))
await db.flush()
await mail.send_off_loop(
@@ -292,7 +301,7 @@ async def verify_email(
):
"""Verify a registration email with the code received by mail."""
eh = hash_email_blind(body.email)
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
result = await db.execute(
select(EmailVerification).where(
@@ -306,7 +315,7 @@ async def verify_email(
raise HTTPException(status_code=404,
detail="No pending verification for this email")
- if verif.expires_at.replace(tzinfo=timezone.utc) < now:
+ if verif.expires_at.replace(tzinfo=UTC) < now:
raise HTTPException(status_code=410, detail="Verification code expired")
if verif.attempts >= VERIFICATION_MAX_ATTEMPTS:
@@ -603,7 +612,7 @@ async def device_auth(
await db.commit()
raise HTTPException(status_code=401, detail="Invalid signature")
- matched.last_seen = datetime.now(timezone.utc)
+ matched.last_seen = datetime.now(UTC)
memberships = await db.execute(
select(GroupMember.group_id).where(GroupMember.user_id == user.id))
@@ -650,7 +659,7 @@ async def token_refresh(
await db.commit()
raise HTTPException(status_code=401, detail="Token reuse detected — family revoked")
- if rt.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
+ if rt.expires_at.replace(tzinfo=UTC) < datetime.now(UTC):
raise HTTPException(status_code=401, detail="Expired refresh token")
user = await db.get(User, rt.user_id)
@@ -659,7 +668,7 @@ async def token_refresh(
# The family's first sign-in was longer ago than any session may last.
expires_at = await _refresh_expiry(db, rt.family_id)
- if expires_at <= datetime.now(timezone.utc):
+ if expires_at <= datetime.now(UTC):
await db.execute(
update(RefreshToken)
.where(RefreshToken.family_id == rt.family_id)
@@ -779,7 +788,7 @@ async def update_profile(
cooldown = await hub_settings.get_int(
db, "mail.email_change_cooldown",
hub_settings.mail_default("email_change_cooldown"))
- since = datetime.now(timezone.utc) - timedelta(seconds=cooldown)
+ since = datetime.now(UTC) - timedelta(seconds=cooldown)
recent = await db.execute(
select(IPLog).where(
IPLog.user_id == current_user.id,
@@ -820,7 +829,7 @@ async def update_profile(
code=code,
purpose="email_change",
user_id=current_user.id,
- expires_at=datetime.now(timezone.utc) + timedelta(seconds=VERIFICATION_TTL),
+ expires_at=datetime.now(UTC) + timedelta(seconds=VERIFICATION_TTL),
))
db.add(IPLog(user_id=current_user.id, event="email_change_request",
ip_address=client_ip(request)))
@@ -860,7 +869,7 @@ async def verify_email_change(
db: AsyncSession = Depends(get_db),
):
"""Confirm an email change with the code sent to the new address."""
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
result = await db.execute(
select(EmailVerification).where(
@@ -874,7 +883,7 @@ async def verify_email_change(
raise HTTPException(status_code=404,
detail="No pending email change")
- if verif.expires_at.replace(tzinfo=timezone.utc) < now:
+ if verif.expires_at.replace(tzinfo=UTC) < now:
raise HTTPException(status_code=410, detail="Verification code expired")
if verif.attempts >= VERIFICATION_MAX_ATTEMPTS:
@@ -1090,7 +1099,7 @@ async def password_reset_request(
EmailVerification.user_id == user.id,
EmailVerification.purpose == "password_reset",
EmailVerification.created_at
- > datetime.now(timezone.utc) - timedelta(seconds=reset_cooldown),
+ > datetime.now(UTC) - timedelta(seconds=reset_cooldown),
))
if recent.first():
return {"status": "sent_if_exists"}
@@ -1110,7 +1119,7 @@ async def password_reset_request(
code=code,
purpose="password_reset",
user_id=user.id,
- expires_at=datetime.now(timezone.utc)
+ expires_at=datetime.now(UTC)
+ timedelta(seconds=PASSWORD_RESET_TTL),
))
db.add(IPLog(user_id=user.id, event="password_reset_request",
@@ -1141,7 +1150,7 @@ async def password_reset(
request: Request,
db: AsyncSession = Depends(get_db),
):
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
result = await db.execute(select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
if not user:
@@ -1157,7 +1166,7 @@ async def password_reset(
if not verif:
raise HTTPException(status_code=404,
detail="No pending reset for this account")
- if verif.expires_at.replace(tzinfo=timezone.utc) < now:
+ if verif.expires_at.replace(tzinfo=UTC) < now:
raise HTTPException(status_code=410, detail="Reset code expired")
if verif.attempts >= VERIFICATION_MAX_ATTEMPTS:
raise HTTPException(status_code=429, detail="Too many attempts")
@@ -1303,7 +1312,9 @@ async def register_node_key(
if len(raw) != 32:
raise ValueError
except Exception:
- raise HTTPException(status_code=400, detail="Invalid Ed25519 public key (need 32 bytes base64)")
+ raise HTTPException(
+ status_code=400,
+ detail="Invalid Ed25519 public key (need 32 bytes base64)")
current_user.pk_node_ed25519 = body.pk_node_ed25519
await db.commit()
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 3e23961..054d04a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -168,7 +168,8 @@ _HTML = """\
though it had scrolled away. This asks for the keyboard to resize the
layout viewport instead, so what is pinned stays where it is looked at.
Ignored by browsers that do not know it. -->
- <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
+ <meta name="viewport"
+ content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<title>MeshBay</title>
<link rel="stylesheet" href="/a/{v}/style.css">
</head>
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index afc1cde..209dc32 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -11,37 +11,41 @@ Usage:
import asyncio
from contextlib import asynccontextmanager
-from pathlib import Path
from fastapi import FastAPI
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from meshbay_hub import __version__
+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
+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
+from meshbay_hub.api.users import set_config as users_set_config
+from meshbay_hub.api.webapp import ASSET_V, CSP, STATIC_DIR
+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
-from meshbay_hub.api.hub import router as hub_router, set_config as hub_set_config
-from meshbay_hub.api.users import router as users_router, set_config as users_set_config
-from meshbay_hub.api.deps import set_admin_usernames
-from meshbay_hub.api.nodes import router as nodes_router
-from meshbay_hub.api.groups import router as groups_router, swarm_router
-from meshbay_hub.api.revocation import router as revocation_router
-from meshbay_hub.api.moderation import router as moderation_router
-from meshbay_hub.api.federation import router as federation_router
-from meshbay_hub.csam import csam_router
-from meshbay_hub.api.health import router as health_router
-from meshbay_hub.api.relay import router as relay_router
-from meshbay_hub.api.signaling import router as signaling_router
-from meshbay_hub.api.admin import router as admin_router
-from meshbay_hub.api.notifications import router as notifications_router
-from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V, CSP
-from meshbay_hub.api.middleware import limiter
async def _sync_admin_roles(admin_usernames: list[str]) -> None:
"""Ensure config-listed admin usernames have role='admin' in the DB."""
- from sqlalchemy import select, update
+ from sqlalchemy import select
+
from meshbay_hub.db.engine import get_session_factory
from meshbay_hub.db.models import User
@@ -122,8 +126,8 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
from meshbay_hub.csam import get_csam_checker
get_csam_checker().load()
- from meshbay_hub.tasks.cleanup import cleanup_loop
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()))
yield
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 7045197..d038027 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -18,12 +18,12 @@ from pathlib import Path
import blake3
import jwt
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives.hashes import SHA256
# Argon2id parameters — versioned for gradual migration
_ARGON2_LANES = 4
diff --git a/packages/meshbay-hub/src/meshbay_hub/csam.py b/packages/meshbay-hub/src/meshbay_hub/csam.py
index 2a0ce25..b8e8d04 100644
--- a/packages/meshbay-hub/src/meshbay_hub/csam.py
+++ b/packages/meshbay-hub/src/meshbay_hub/csam.py
@@ -19,11 +19,14 @@ IMPORTANT: Never log matched hashes or file contents. CSAM detection
must be reported to NCMEC (US law) or relevant authority immediately.
"""
-import hashlib
import logging
-import os
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)
@@ -123,10 +126,6 @@ def check_content_hash(blake3_hex: str) -> bool:
# ── Hub API integration ───────────────────────────────────────────────────────
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
-from meshbay_hub.api.deps import require_admin
-from meshbay_hub.db.models import User
-
csam_router = APIRouter(prefix="/v1/admin/csam", tags=["csam"])
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/__init__.py b/packages/meshbay-hub/src/meshbay_hub/db/__init__.py
index 62e5388..9d7483a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/__init__.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/__init__.py
@@ -1,6 +1,6 @@
"""Hub database layer."""
-from .engine import init_db, close_db, get_db
-from .models import Base, User, Node, Group, GroupMember, RefreshToken, IPLog
+from .engine import close_db, get_db, init_db
+from .models import Base, Group, GroupMember, IPLog, Node, RefreshToken, User
__all__ = [
"init_db", "close_db", "get_db",
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py
index 8908deb..61f80be 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py
@@ -4,14 +4,12 @@ import asyncio
import os
from logging.config import fileConfig
+from alembic import context
+from meshbay_hub.db.models import Base
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
-from alembic import context
-
-from meshbay_hub.db.models import Base
-
config = context.config
if config.config_file_name is not None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py
index 8a4e2ee..034d12f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py
@@ -5,16 +5,15 @@ Revises: f1a2b3c4d5e6
Create Date: 2026-08-20 20:50:00.000000
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
-from alembic import op
import sqlalchemy as sa
-
+from alembic import op
revision: str = 'a7b8c9d0e1f2'
-down_revision: Union[str, Sequence[str], None] = 'f1a2b3c4d5e6'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = 'f1a2b3c4d5e6'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py
index 2fead6c..45985e2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py
@@ -7,15 +7,15 @@ Revision ID: a9b8c7d6e5f4
Revises: e5f6a7b8c9d0
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "a9b8c7d6e5f4"
-down_revision: Union[str, Sequence[str], None] = "e5f6a7b8c9d0"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py
index 13f2b5c..9a90f7a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py
@@ -5,16 +5,15 @@ Revises: a7b8c9d0e1f2
Create Date: 2026-08-28 12:00:00.000000
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
-from alembic import op
import sqlalchemy as sa
-
+from alembic import op
revision: str = 'b1c2d3e4f5a6'
-down_revision: Union[str, Sequence[str], None] = 'a7b8c9d0e1f2'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = 'a7b8c9d0e1f2'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py
index 6fc5b73..fea0583 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py
@@ -10,16 +10,15 @@ its UUID — this only makes `name@owner` a dependable handle.
Pre-flight: abort if the data already violates it, with the offending
(admin_id, name) pairs listed, rather than silently renaming anyone's group.
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
-from alembic import op
import sqlalchemy as sa
-
+from alembic import op
revision: str = 'c3d4e5f6a7b8'
-down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = 'b1c2d3e4f5a6'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py
index 6301426..a814f37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py
@@ -12,16 +12,15 @@ Revises:
Create Date: 2026-08-09 04:35:07.120021
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
-from alembic import op
import sqlalchemy as sa
-
+from alembic import op
revision: str = 'd28b9caf9f07'
-down_revision: Union[str, Sequence[str], None] = None
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = None
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py
index 6741eb8..aa15b6d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py
@@ -9,16 +9,15 @@ Revises: c3d4e5f6a7b8
Create Date: 2026-08-31 14:00:00.000000
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
-from alembic import op
import sqlalchemy as sa
-
+from alembic import op
revision: str = 'd4e5f6a7b8c9'
-down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = 'c3d4e5f6a7b8'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5f6a7b8c9d0_add_mail_quota.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5f6a7b8c9d0_add_mail_quota.py
index 8f2e4d2..c729de4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5f6a7b8c9d0_add_mail_quota.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5f6a7b8c9d0_add_mail_quota.py
@@ -8,15 +8,15 @@ Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "e5f6a7b8c9d0"
-down_revision: Union[str, Sequence[str], None] = "d4e5f6a7b8c9"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py
index e30a4da..9f8e729 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py
@@ -5,16 +5,15 @@ Revises: d28b9caf9f07
Create Date: 2026-08-19 12:00:00.000000
"""
-from typing import Sequence, Union
+from collections.abc import Sequence
-from alembic import op
import sqlalchemy as sa
-
+from alembic import op
revision: str = 'f1a2b3c4d5e6'
-down_revision: Union[str, Sequence[str], None] = 'd28b9caf9f07'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = 'd28b9caf9f07'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 62e4a11..ac1828f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -11,17 +11,23 @@ Tables:
"""
import uuid
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from sqlalchemy import (
- Boolean, DateTime, ForeignKey, Index, Integer,
- String, Text, UniqueConstraint, text,
+ Boolean,
+ DateTime,
+ ForeignKey,
+ Index,
+ Integer,
+ String,
+ Text,
+ text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def _now() -> datetime:
- return datetime.now(timezone.utc)
+ return datetime.now(UTC)
def _uuid() -> str:
return str(uuid.uuid4())
@@ -47,10 +53,12 @@ class User(Base):
# the node does the wrapping, nothing reads a key from this directory. Keys
# are generated per node and pinned there (meshbay_node/roster.py).
email_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) # HMAC blind index
- pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True) # node daemon key
+ pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True)
hub_id: Mapped[str] = mapped_column(String(128), nullable=False)
- role: Mapped[str] = mapped_column(String(16), default="user") # user|moderator|admin
- status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked
+ # user|moderator|admin
+ role: Mapped[str] = mapped_column(String(16), default="user")
+ # active|suspended|revoked
+ status: Mapped[str] = mapped_column(String(16), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
nodes: Mapped[list["Node"]] = relationship(back_populates="user")
@@ -99,7 +107,8 @@ class Group(Base):
visibility: Mapped[str] = mapped_column(String(16), default="private") # public|private
join_policy: Mapped[str] = mapped_column(String(16), default="invite") # open|request|invite
description: Mapped[str | None] = mapped_column(String(512))
- status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked
+ # active|suspended|revoked
+ status: Mapped[str] = mapped_column(String(16), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
# First time a node registered on /v1/nodes/ws announcing that it hosts this
# group. Until then the group has no files, no key and nobody to serve it, so
@@ -376,7 +385,8 @@ class IPLog(Base):
__tablename__ = "ip_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id")) # null for failed logins
+ # null for failed logins
+ user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
# The name this account had, written when it is deleted. The username is
# released on deletion and the row itself is tombstoned, so the join that
# normally supplies the name would answer "deleted-3f9a1c" for exactly the
@@ -384,7 +394,8 @@ class IPLog(Base):
username: Mapped[str | None] = mapped_column(String(64))
event: Mapped[str] = mapped_column(String(32), nullable=False)
ip_address: Mapped[str] = mapped_column(String(45), nullable=False) # IPv4 or IPv6
- detail: Mapped[str | None] = mapped_column(String(256)) # e.g. username on fail
+ # e.g. username on fail
+ detail: Mapped[str | None] = mapped_column(String(256))
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
user: Mapped["User | None"] = relationship(back_populates="ip_logs")
diff --git a/packages/meshbay-hub/src/meshbay_hub/login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
index 3281088..bfd142e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
+++ b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
@@ -24,7 +24,7 @@ locks somebody else's name from signing them out (§13.5b, AV26).
"""
import hashlib
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from sqlalchemy import case, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,7 +39,7 @@ def _key(username: str) -> str:
def _aware(dt: datetime) -> datetime:
# SQLite hands back naive datetimes for a timezone-aware column.
- return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
def _insert_for(db: AsyncSession):
@@ -64,7 +64,7 @@ async def reserve(db: AsyncSession, username: str) -> tuple[bool, int]:
if max_failures == 0:
return True, 0
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
window = timedelta(minutes=limits["lockout_minutes"])
window_start = now - window
key = _key(username)
@@ -102,7 +102,7 @@ async def locked_for(db: AsyncSession, username: str) -> int:
return 0
remaining = (_aware(row.last_failure_at)
+ timedelta(minutes=limits["lockout_minutes"])
- - datetime.now(timezone.utc)).total_seconds()
+ - datetime.now(UTC)).total_seconds()
return max(0, int(remaining + 0.999))
@@ -136,7 +136,7 @@ async def clear(db: AsyncSession, username: str) -> None:
async def purge_expired(db: AsyncSession) -> int:
"""Rows whose failures have aged out. Every unknown name typed creates one."""
limits = await hub_settings.login_limits(db)
- cutoff = datetime.now(timezone.utc) - timedelta(minutes=limits["lockout_minutes"])
+ cutoff = datetime.now(UTC) - timedelta(minutes=limits["lockout_minutes"])
result = await db.execute(
delete(LoginThrottle).where(LoginThrottle.last_failure_at < cutoff))
await db.commit()
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index 126ed45..1eb7c51 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -16,7 +16,7 @@ import asyncio
import hashlib
import logging
import smtplib
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from email.message import EmailMessage
log = logging.getLogger(__name__)
@@ -79,7 +79,7 @@ async def _take(db, key: str, window: timedelta, ceiling: int,
"""
from meshbay_hub.db.models import MailQuota
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
row = await db.get(MailQuota, key)
if row is None:
row = MailQuota(key=key, window_start=now, count=0, last_sent=None)
@@ -87,14 +87,14 @@ async def _take(db, key: str, window: timedelta, ceiling: int,
started = row.window_start
if started.tzinfo is None:
- started = started.replace(tzinfo=timezone.utc)
+ started = started.replace(tzinfo=UTC)
if now - started >= window:
row.window_start, row.count = now, 0
if cooldown is not None and row.last_sent is not None:
last = row.last_sent
if last.tzinfo is None:
- last = last.replace(tzinfo=timezone.utc)
+ last = last.replace(tzinfo=UTC)
if now - last < cooldown:
raise MailRefused("too soon since the last message to this recipient")
@@ -131,12 +131,12 @@ async def _announce_exhaustion(scope: str) -> None:
try:
async with get_session_factory()() as db:
key = f"alert:{scope}"
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
row = await db.get(MailQuota, key)
if row is not None and row.last_sent is not None:
last = row.last_sent
if last.tzinfo is None:
- last = last.replace(tzinfo=timezone.utc)
+ last = last.replace(tzinfo=UTC)
if now - last < timedelta(hours=1):
return
if row is None:
@@ -202,9 +202,10 @@ async def reserve(db, purpose: str, address: str) -> None:
async def status(db) -> dict:
"""What the operator sees in the panel: is the hub still sending?"""
+ from sqlalchemy import func, select
+
from meshbay_hub import hub_settings
from meshbay_hub.db.models import MailQuota
- from sqlalchemy import func, select
limits = await hub_settings.mail_limits(db)
row = await db.get(MailQuota, "hour")
@@ -213,8 +214,8 @@ async def status(db) -> dict:
if row is not None:
started = row.window_start
if started.tzinfo is None:
- started = started.replace(tzinfo=timezone.utc)
- if datetime.now(timezone.utc) - started < timedelta(hours=1):
+ started = started.replace(tzinfo=UTC)
+ if datetime.now(UTC) - started < timedelta(hours=1):
used, window_start = row.count, started.isoformat()
recipients = await db.scalar(
@@ -244,10 +245,11 @@ async def status(db) -> dict:
async def purge_expired_quota(db) -> int:
"""Drop counters whose window has passed. Returns how many went."""
- from meshbay_hub.db.models import MailQuota
from sqlalchemy import delete
- cutoff = datetime.now(timezone.utc) - timedelta(days=1)
+ from meshbay_hub.db.models import MailQuota
+
+ cutoff = datetime.now(UTC) - timedelta(days=1)
result = await db.execute(
delete(MailQuota).where(MailQuota.window_start < cutoff))
await db.commit()
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
index 5c52387..1ef7796 100644
--- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
@@ -2,7 +2,7 @@
import asyncio
import logging
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +16,7 @@ CLEANUP_INTERVAL_HOURS = 24
async def purge_old_ip_logs(db: AsyncSession, retention_days: int = RETENTION_DAYS) -> int:
- cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
+ cutoff = datetime.now(UTC) - timedelta(days=retention_days)
result = await db.execute(delete(IPLog).where(IPLog.timestamp < cutoff))
await db.commit()
return result.rowcount
@@ -26,7 +26,7 @@ PENDING_USER_EXPIRY_DAYS = 7
async def purge_expired_verifications(db: AsyncSession) -> int:
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
result = await db.execute(
delete(EmailVerification).where(EmailVerification.expires_at < now))
await db.commit()
@@ -35,7 +35,7 @@ async def purge_expired_verifications(db: AsyncSession) -> int:
async def purge_stale_pending_users(db: AsyncSession,
expiry_days: int = PENDING_USER_EXPIRY_DAYS) -> int:
- cutoff = datetime.now(timezone.utc) - timedelta(days=expiry_days)
+ cutoff = datetime.now(UTC) - timedelta(days=expiry_days)
result = await db.execute(
delete(User).where(User.status == "pending", User.created_at < cutoff))
await db.commit()
@@ -50,7 +50,8 @@ async def cleanup_loop(get_session):
async with get_session() as db:
deleted = await purge_old_ip_logs(db)
if deleted:
- log.info("Purged %d IP log entries older than %d days", deleted, RETENTION_DAYS)
+ log.info("Purged %d IP log entries older than %d days",
+ deleted, RETENTION_DAYS)
expired = await purge_expired_verifications(db)
if expired:
log.info("Purged %d expired email verifications", expired)
@@ -93,7 +94,7 @@ async def find_unhosted_groups(db: AsyncSession, grace_days: int = UNHOSTED_GRAC
whole reason the column exists rather than a check against the live socket
registry, which would delete every group during a hub restart.
"""
- cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
+ cutoff = datetime.now(UTC) - timedelta(days=grace_days)
result = await db.execute(
select(Group).where(Group.hosted_at.is_(None), Group.created_at < cutoff))
return list(result.scalars().all())