summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/app.py
blob: 76d7ec0e8d4a9a5693d41f425ec64141b63783b9 (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
"""
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
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)

    # 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