gabriel / musehub public
main.py python
269 lines 11.7 KB
7f1d07e8 feat: domains, MCP expansion, MIDI player, and production hardening (#8) Gabriel Cardona <cgcardona@gmail.com> 4d ago
1 """
2 MuseHub API
3
4 Standalone FastAPI application for the music composition version control platform.
5 GitHub for music — push commits, open pull requests, track issues, publish releases.
6 """
7
8 import logging
9 from contextlib import asynccontextmanager
10 from collections.abc import AsyncIterator
11 from typing import Awaitable, Callable
12
13 from pathlib import Path
14
15 from fastapi import FastAPI, Request
16 from fastapi.middleware.cors import CORSMiddleware
17 from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
18 from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
19 from fastapi.staticfiles import StaticFiles
20 from starlette.middleware.base import BaseHTTPMiddleware
21 from starlette.responses import Response
22 from slowapi import Limiter, _rate_limit_exceeded_handler
23 from slowapi.util import get_remote_address
24 from slowapi.errors import RateLimitExceeded
25
26 from musehub.config import settings
27 from musehub.api.routes.musehub import (
28 ui as musehub_ui_routes,
29 ui_milestones as musehub_ui_milestones_routes,
30 ui_stash as musehub_ui_stash_routes,
31 ui_blame as musehub_ui_blame_routes,
32 ui_notifications as musehub_ui_notifications_routes,
33 ui_collaborators as musehub_ui_collab_routes,
34 ui_labels as musehub_ui_labels_routes,
35 ui_settings as musehub_ui_settings_routes,
36 ui_similarity as musehub_ui_similarity_routes,
37 ui_topics as musehub_ui_topics_routes,
38 ui_forks as musehub_ui_forks_routes,
39 ui_emotion_diff as musehub_ui_emotion_diff_routes,
40 ui_user_profile as musehub_ui_profile_routes,
41 ui_mcp_elicitation as musehub_ui_mcp_elicitation_routes,
42 ui_new_repo as musehub_ui_new_repo_routes,
43 ui_domains as musehub_ui_domains_routes,
44 domains as musehub_domains_routes,
45 discover as musehub_discover_routes,
46 users as musehub_user_routes,
47 oembed as musehub_oembed_routes,
48 raw as musehub_raw_routes,
49 sitemap as musehub_sitemap_routes,
50 )
51 from musehub.api.routes import musehub as musehub_router_pkg
52 from musehub.api.routes.mcp import router as mcp_router
53 from musehub.api.routes.musehub.ui_view import view_router as musehub_ui_view_router
54 from musehub.api.routes.musehub.ui_view import redirect_router as musehub_ui_redirect_router
55 from musehub.db import init_db, close_db
56
57
58 class SecurityHeadersMiddleware(BaseHTTPMiddleware):
59 """Add security headers to all responses."""
60
61 async def dispatch(
62 self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
63 ) -> Response:
64 response = await call_next(request)
65
66 if "X-Frame-Options" not in response.headers:
67 response.headers["X-Frame-Options"] = "DENY"
68 response.headers["X-Content-Type-Options"] = "nosniff"
69 response.headers["X-XSS-Protection"] = "1; mode=block"
70 response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
71 response.headers["Permissions-Policy"] = (
72 "accelerometer=(), camera=(), geolocation=(), "
73 "gyroscope=(), magnetometer=(), microphone=(), "
74 "payment=(), usb=()"
75 )
76 response.headers["Content-Security-Policy"] = (
77 "default-src 'self'; "
78 "script-src 'self' 'unsafe-inline'; "
79 "style-src 'self' 'unsafe-inline' https://fonts.bunny.net; "
80 "font-src 'self' https://fonts.bunny.net; "
81 "img-src 'self' data: https:; "
82 "connect-src 'self'; "
83 "frame-ancestors 'none'"
84 )
85 if not settings.debug:
86 response.headers["Strict-Transport-Security"] = (
87 "max-age=63072000; includeSubDomains; preload"
88 )
89 # Prevent fingerprinting — suppress the default "uvicorn" server banner
90 response.headers["Server"] = "musehub"
91 return response
92
93
94 logging.basicConfig(
95 level=logging.DEBUG if settings.debug else logging.INFO,
96 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
97 )
98 logger = logging.getLogger(__name__)
99
100 limiter = Limiter(key_func=get_remote_address)
101
102
103 @asynccontextmanager
104 async def lifespan(app: FastAPI) -> AsyncIterator[None]:
105 """Application lifespan handler."""
106 logger.info(f"Starting MuseHub v{settings.app_version}")
107
108 try:
109 await init_db()
110 logger.info("✅ Database initialized")
111 except Exception as e:
112 logger.error(f"❌ Failed to initialize database: {e}")
113 raise
114
115 if not settings.debug and settings.database_url and "postgres" in settings.database_url:
116 pw = (settings.db_password or "").strip()
117 weak = {"", "changeme123", "musehub", "password", "postgres", "secret"}
118 if not pw or pw in weak:
119 raise RuntimeError(
120 "Production requires DB_PASSWORD set to a strong value. "
121 "Generate with: openssl rand -hex 16"
122 )
123
124 yield
125
126 logger.info("Shutting down MuseHub...")
127 await close_db()
128
129
130 app = FastAPI(
131 title="MuseHub API",
132 version=settings.app_version,
133 # OpenAPI schema served in debug/dev and test environments.
134 # In production (DEBUG=false, MUSE_ENV != "test") it is disabled — agents use /mcp instead.
135 openapi_url="/api/v1/openapi.json" if (settings.debug or settings.muse_env == "test") else None,
136 description=(
137 "**MuseHub** — the version control hub for multidimensional state, powered by Muse.\n\n"
138 "Muse is a domain-agnostic version control system. Not just Git — any state space "
139 "where a 'change' is a delta across multiple axes simultaneously. "
140 "MIDI (21 dimensions), code (symbol graph), genomics, climate simulation, 3D design.\n\n"
141 "MuseHub gives AI agents and humans a GitHub-style workflow for any Muse domain: "
142 "push commits, open pull requests, track issues, browse domain insights, and "
143 "discover registered domain plugins — all via a machine-readable OpenAPI + MCP spec.\n\n"
144 "## Authentication\n\n"
145 "All write endpoints and private-repo reads require a **Bearer JWT** in the "
146 "`Authorization` header:\n\n"
147 "```\nAuthorization: Bearer <your-jwt>\n```\n\n"
148 "Public repo read endpoints accept unauthenticated requests.\n\n"
149 "## MCP (Model Context Protocol)\n\n"
150 "Full MCP 2025-11-25 Streamable HTTP at `POST /GET /DELETE /mcp`. "
151 "37 tools, 27 resource URIs (`muse://...`), and 11 prompts. "
152 "See `/mcp/docs` for the interactive reference.\n\n"
153 "## URL Scheme\n\n"
154 "Repos: `/{owner}/{slug}` · Domain viewer: `/{owner}/{slug}/view/{ref}` · "
155 "Insights: `/{owner}/{slug}/insights/{ref}/{dim}`\n"
156 "Clone URL: `musehub://{owner}/{slug}`\n"
157 ),
158 contact={
159 "name": "Muse VCS",
160 "url": "https://musehub.app",
161 "email": "hello@musehub.app",
162 },
163 license_info={
164 "name": "Proprietary",
165 "url": "https://musehub.app/terms",
166 },
167 lifespan=lifespan,
168 # Docs are served via custom routes below that use locally-bundled assets,
169 # so we disable the default CDN-dependent auto-generated routes.
170 docs_url=None,
171 redoc_url=None,
172 )
173
174
175 def _handle_rate_limit(request: Request, exc: Exception) -> Response:
176 if isinstance(exc, RateLimitExceeded):
177 result: Response = _rate_limit_exceeded_handler(request, exc)
178 return result
179 raise exc
180
181
182 app.state.limiter = limiter
183 app.add_exception_handler(RateLimitExceeded, _handle_rate_limit)
184 app.add_middleware(SecurityHeadersMiddleware)
185
186 if "*" in settings.cors_origins:
187 logger.warning("SECURITY WARNING: CORS allows all origins. Set CORS_ORIGINS in production.")
188 app.add_middleware(
189 CORSMiddleware,
190 allow_origins=settings.cors_origins,
191 allow_credentials=True,
192 allow_methods=["*"],
193 allow_headers=["*"],
194 )
195
196 # Static files mounted FIRST — must come before the /{owner}/{repo_slug} wildcard
197 # UI routes, otherwise "static" would be matched as an owner name.
198 _STATIC_DIR = Path(__file__).parent / "templates" / "musehub" / "static"
199 app.mount(
200 "/static",
201 StaticFiles(directory=str(_STATIC_DIR)),
202 name="static",
203 )
204
205 # Fixed-prefix subrouters registered BEFORE the main musehub router
206 # so their concrete paths are matched first, not shadowed by /{owner}/{repo_slug}.
207 app.include_router(musehub_user_routes.router, prefix="/api/v1", tags=["Users"])
208 app.include_router(musehub_discover_routes.router, prefix="/api/v1", tags=["Discover"])
209 app.include_router(musehub_discover_routes.star_router, prefix="/api/v1", tags=["Social"])
210 app.include_router(musehub_domains_routes.router, prefix="/api/v1", tags=["Domains"])
211 app.include_router(musehub_router_pkg.router, prefix="/api/v1")
212 app.include_router(musehub_ui_notifications_routes.router, tags=["musehub-ui-notifications"])
213 app.include_router(musehub_ui_topics_routes.router, tags=["musehub-ui"])
214 app.include_router(musehub_ui_mcp_elicitation_routes.router, tags=["musehub-ui-mcp"])
215 app.include_router(musehub_ui_new_repo_routes.router, tags=["musehub-ui"])
216 app.include_router(musehub_ui_domains_routes.router, tags=["musehub-ui-domains"])
217 app.include_router(musehub_ui_routes.fixed_router, tags=["musehub-ui"])
218 app.include_router(musehub_ui_milestones_routes.router, tags=["musehub-ui"])
219 app.include_router(musehub_ui_stash_routes.router, tags=["musehub-ui-stash"])
220 app.include_router(musehub_ui_forks_routes.router, tags=["musehub-ui"])
221 app.include_router(musehub_ui_collab_routes.router, tags=["musehub-ui"])
222 app.include_router(musehub_ui_labels_routes.router, tags=["musehub-ui"])
223
224 # Fixed-path routers that must come BEFORE the /{owner}/{repo_slug} wildcard in ui_routes.router.
225 # Registering them after would cause /oembed, /oembed/commit, /sitemap.xml, /mcp, etc.
226 # to be shadowed and matched as if "oembed"/"sitemap"/"mcp" were repo owner names.
227 app.include_router(musehub_oembed_routes.router, tags=["musehub-oembed"])
228 app.include_router(musehub_raw_routes.router, prefix="/api/v1", tags=["musehub-raw"])
229 app.include_router(musehub_sitemap_routes.router, tags=["musehub-sitemap"])
230 app.include_router(mcp_router)
231
232 # Wildcard UI routes — /{owner}/{repo_slug} and deeper paths.
233 # Must come after all fixed-path routers above.
234 # Redirect router must come before view_router so /piano-roll, /listen, /arrange
235 # are caught and 301'd before they'd match as owner names.
236 app.include_router(musehub_ui_redirect_router, tags=["musehub-ui-redirects"])
237 app.include_router(musehub_ui_view_router, tags=["musehub-ui-view"])
238 app.include_router(musehub_ui_routes.router, tags=["musehub-ui"])
239 app.include_router(musehub_ui_blame_routes.router, tags=["musehub-ui"])
240 app.include_router(musehub_ui_settings_routes.router, tags=["musehub-ui-settings"])
241 app.include_router(musehub_ui_similarity_routes.router, tags=["musehub-ui"])
242 app.include_router(musehub_ui_emotion_diff_routes.router, tags=["musehub-ui"])
243
244 # Profile catch-all MUST be last — /{username} is a single-segment wildcard and
245 # would shadow fixed routes (e.g. /explore, /feed, /topics, /mcp) if registered earlier.
246 app.include_router(musehub_ui_profile_routes.router, tags=["musehub-ui"])
247
248 if settings.debug:
249 @app.get("/docs", include_in_schema=False)
250 async def swagger_ui() -> HTMLResponse:
251 return get_swagger_ui_html(
252 openapi_url="/api/v1/openapi.json",
253 title="MuseHub API — Swagger UI",
254 swagger_js_url="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js",
255 swagger_css_url="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css",
256 )
257
258 @app.get("/redoc", include_in_schema=False)
259 async def redoc_ui() -> HTMLResponse:
260 return get_redoc_html(
261 openapi_url="/api/v1/openapi.json",
262 title="MuseHub API — ReDoc",
263 )
264
265
266 @app.get("/", include_in_schema=False)
267 async def root() -> RedirectResponse:
268 """Redirect browsers to the UI; agents should use /api/v1/openapi.json."""
269 return RedirectResponse(url="/explore")