aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/app.py
blob: b1d9626d41b8db49a85736629508ebc1e8bd58b8 (plain) (blame)
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""
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 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.invite_links import redeem_router as invite_links_redeem_router
from meshbay_hub.api.invite_links import router as invite_links_router
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


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

    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 hub_settings as _hub_settings
        from meshbay_hub import mail as _mail
        _mail.configure(cfg.identity.id)
        # What `hub.toml` says the mail bounds are. The live values are read
        # from `hub_settings` at each use, so an admin can change them from the
        # panel while the hub is serving; these are what a missing row falls
        # back to.
        _hub_settings.set_mail_defaults(cfg.mail)

        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()))

        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,
        # No interactive docs and no schema. The full description of the
        # identity authority's API is a map for whoever probes it, and nothing
        # in the tree reads it. meshbay.org hid these in its Caddyfile (S18),
        # which protects exactly one deployment: a hub installed from the
        # package, behind any other proxy, published all three.
        docs_url=None,
        redoc_url=None,
        openapi_url=None,
    )

    # 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")
        # SAMEORIGIN, matching `frame-ancestors 'self'` in the CSP above.
        #
        # The two say the same thing to different generations of browser, and
        # they were saying different things: CSP allowed this origin to frame
        # itself, this header forbade all framing. The spec says a browser must
        # ignore X-Frame-Options when the CSP carries frame-ancestors — but
        # relying on that while shipping a header that contradicts our own
        # policy is asking to be surprised, and we were: the streamed download
        # (a hidden iframe onto `/_mbdl/<id>`, the only way to write a large
        # file to disk on Firefox and Safari) stayed blocked after the CSP was
        # fixed, and this header was why it looked like the fix had not worked.
        #
        # No foreign origin may frame this page under either spelling. That is
        # the property; DENY was one notch stricter than the property needed and
        # broke a feature to get there.
        response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
        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(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)
    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