1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
|
"""
MeshBay Hub — FastAPI application factory.
Usage:
from meshbay_hub.app import create_app
from meshbay_hub.config import load_config
cfg = load_config()
app = create_app(cfg)
"""
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.auth import generate_hub_keypair, load_hub_keypair
from meshbay_hub.config import HubConfig
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 meshbay_hub.db.engine import get_session_factory
from meshbay_hub.db.models import User
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(User).where(User.username.in_(admin_usernames))
)
for user in result.scalars().all():
if user.role != "admin":
user.role = "admin"
await session.commit()
async def _backfill_email_hashes() -> None:
"""One-time backfill: compute email_hash for users that don't have one yet."""
import logging
from sqlalchemy import select
from meshbay_hub.auth import decrypt_email, hash_email_blind
from meshbay_hub.db.engine import get_session_factory
from meshbay_hub.db.models import User
log = logging.getLogger(__name__)
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(User).where(User.email_hash.is_(None), User.email.isnot(None))
)
users = list(result.scalars().all())
if not users:
return
count = 0
for user in users:
try:
plain = decrypt_email(user.email)
user.email_hash = hash_email_blind(plain)
count += 1
except Exception:
log.warning("Could not backfill email_hash for user %s", user.id)
await session.commit()
log.info("Backfilled email_hash for %d users", count)
def create_app(cfg: HubConfig | None = None) -> FastAPI:
from meshbay_hub.config import load_config
if cfg is None:
cfg = load_config()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await init_db(cfg.db.url)
kp = cfg.identity.private_key_path
if not kp.exists():
generate_hub_keypair(kp)
load_hub_keypair(kp, cfg.identity.id)
users_set_config(cfg)
hub_set_config(cfg)
set_admin_usernames(cfg.identity.admin_usernames)
await _backfill_email_hashes()
from meshbay_hub import mail as _mail
_mail.configure(cfg.identity.id)
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.tasks.cleanup import cleanup_loop
from meshbay_hub.db.engine import get_session_factory
cleanup_task = asyncio.create_task(cleanup_loop(get_session_factory()))
yield
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Shutdown
await close_db()
app = FastAPI(
title="MeshBay Hub",
version=__version__,
description="MeshBay identity authority and group registry",
lifespan=lifespan,
)
# Rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.middleware("http")
async def _security_headers(request, call_next):
"""
Second-review L5, third-review M5: the SPA shell and its assets went out
with no CSP and no other protective headers. This adds them everywhere —
`webapp.CSP` is the same policy the desktop client already enforces on
these exact files. `setdefault` so a route that sets its own wins.
"""
response = await call_next(request)
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("X-Frame-Options", "DENY")
return response
# Routers (webapp last — catches / before API routes)
app.include_router(hub_router)
app.include_router(users_router)
app.include_router(nodes_router)
app.include_router(groups_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)
app.include_router(webapp_router)
from starlette.staticfiles import StaticFiles
class RevalidatingStatics(StaticFiles):
"""Static assets that must be revalidated, never served blind from cache.
The SPA is a module graph that has to agree with itself: `app.js` imports
`i18n.js`, which imports a catalogue from `locales/`. Serving one of them
from cache while fetching another is not a stale page, it is a broken
one — a browser holding the previous `i18n.js` fails to link the new
`app.js` ("does not provide an export named 'initLocale'") before any
code runs, and the reverse pairing renders every string as its own key.
Starlette sends only `etag` and `last-modified`, and with no explicit
freshness a browser is entitled to guess one. `no-cache` does not
disable caching: it requires a conditional request, which the existing
ETag answers with a 304 and no body.
"""
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers.setdefault("Cache-Control", "no-cache")
return response
class VersionedStatics(StaticFiles):
"""The same files under a URL that changes when they do.
`no-cache` above only binds a browser that asks. One that cached the SPA
before that header existed applies heuristic freshness and does not ask
at all, so it runs an old player against a new node — a fix that is
deployed, served, and not running, which looks exactly like a fix that
does not work. Measured: a phone kept a player without the read-ahead
bound and filled the browser's buffer ceiling at 106 MB, while the
server had been serving the bounded one for an hour.
Serving the graph under /a/<fingerprint>/ solves it for every file at
once, because relative imports inherit the prefix: `app.js` reaching for
`./i18n.js` gets the version it was built against, and never a mixture.
The URL changes with the content, so these may be cached hard.
"""
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable")
return response
# Before "/", which would otherwise swallow it.
app.mount(f"/a/{ASSET_V}", VersionedStatics(directory=STATIC_DIR),
name="static-versioned")
# Still served unversioned: sw.js must stay at the root or its scope stops
# covering the pages it intercepts downloads for, and old bookmarks of
# /style.css and the like should not 404.
app.mount("/", RevalidatingStatics(directory=STATIC_DIR), name="static")
return app
|