"""TTS Voice Creator - Clone and Design — FastAPI entry point (thin wrapper).""" from __future__ import annotations import logging import os import time import uuid from logging.handlers import RotatingFileHandler from pathlib import Path from contextlib import asynccontextmanager from urllib.parse import parse_qs from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware from starlette.datastructures import MutableHeaders from core.constants import STATIC_DIR, _BufferHandler, _MAX_JSON_BYTES, _MAX_UPLOAD_BYTES, _MAX_RAW_REQUEST_BYTES, _REQUIRE_EXTERNAL_API_KEY from core.config import _load_settings, _ensure_external_api_key from core.voice_index import refresh_voice_index_background from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history, jobs, magazine, magazine_sources logger = logging.getLogger("uvicorn.error") # ── App ─────────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): # Uvicorn resets loggers during startup when run via CLI. # We must attach our handlers here to ensure they capture the active logs. for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"): l = logging.getLogger(name) if _buf_handler not in l.handlers: l.addHandler(_buf_handler) if "_file_handler" in globals() and _file_handler not in l.handlers: l.addHandler(_file_handler) try: refresh_voice_index_background(_load_settings()) except Exception as exc: logger.warning("Could not start voice index refresh: %s", exc) yield app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan) # ── Response compression ────────────────────────────────────────────────────── # Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the # large JS bundle and voice-list JSON. Skips already-compressed audio/images. app.add_middleware(GZipMiddleware, minimum_size=1024) # ── Request-size guard ──────────────────────────────────────────────────────── # FastAPI's UploadFile handlers stream individual files, but JSON and raw-body # endpoints otherwise accept an unbounded Content-Length before their handlers # get a chance to validate it. Reject oversized requests early. The existing # per-file _copy_limited checks remain authoritative for multipart contents. class RequestSizeLimitMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} raw_length = headers.get("content-length", "") try: content_length = int(raw_length) if raw_length else 0 except ValueError: content_length = 0 content_type = headers.get("content-type", "").lower() if content_type.startswith("application/json"): limit = _MAX_JSON_BYTES elif content_type.startswith("multipart/"): # Multipart framing adds a small amount around each uploaded file. limit = _MAX_UPLOAD_BYTES + 8 * 1024 * 1024 else: limit = _MAX_RAW_REQUEST_BYTES if content_length > limit: body = b'{"detail":"Request body is too large"}' await send({ "type": "http.response.start", "status": 413, "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())], }) await send({"type": "http.response.body", "body": body}) return await self.app(scope, receive, send) app.add_middleware(RequestSizeLimitMiddleware) # ── Mutation rate limit ─────────────────────────────────────────────────────── # This is intentionally small and process-local: it is a last-resort guard # against accidental loops/double-clicks, not a replacement for a reverse # proxy's distributed rate limiter. Long-running generation requests count once # when started and are not held open by this window. class MutationRateLimitMiddleware: _window_seconds = 60.0 _general_limit = 120 _destructive_limit = 30 _destructive_prefixes = ( "/api/voice", "/api/characters", "/api/rehearsals", "/api/reader/docs", "/api/settings", "/api/local-containers", "/api/line-audio", "/api/logs", "/api/tts-routing-log", "/mcp", ) def __init__(self, app): self.app = app self._hits: dict[tuple[str, bool], list[float]] = {} async def __call__(self, scope, receive, send): if scope["type"] != "http" or scope.get("method", "GET") not in {"POST", "PUT", "PATCH", "DELETE"}: await self.app(scope, receive, send) return path = scope.get("path", "") is_destructive = path.startswith(self._destructive_prefixes) client = scope.get("client") or ("unknown", 0) key = (str(client[0]), is_destructive) now = time.monotonic() hits = [stamp for stamp in self._hits.get(key, []) if now - stamp < self._window_seconds] limit = self._destructive_limit if is_destructive else self._general_limit if len(hits) >= limit: retry_after = max(1, int(self._window_seconds - (now - hits[0]))) body = b'{"detail":"Too many mutation requests; try again later"}' await send({ "type": "http.response.start", "status": 429, "headers": [ (b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()), (b"retry-after", str(retry_after).encode()), ], }) await send({"type": "http.response.body", "body": body}) return hits.append(now) self._hits[key] = hits # Remove stale client buckets opportunistically so a long-lived process # does not retain one list for every historical source IP forever. if len(self._hits) > 2048: self._hits = {k: v for k, v in self._hits.items() if v and now - v[-1] < self._window_seconds} await self.app(scope, receive, send) app.add_middleware(MutationRateLimitMiddleware) # ── Request/job correlation logging ────────────────────────────────────────── # Keep request diagnostics useful without recording request bodies. Route-level # logs add provider/job details where appropriate; this middleware supplies one # stable correlation ID and timing envelope for every API/MCP request. class RequestCorrelationMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return path = scope.get("path", "") if not (path.startswith("/api/") or path == "/mcp"): await self.app(scope, receive, send) return request_id = next((value.decode("latin-1") for key, value in scope.get("headers", []) if key.lower() == b"x-request-id"), "") if not request_id or len(request_id) > 80 or any(ord(ch) < 33 or ord(ch) > 126 for ch in request_id): request_id = uuid.uuid4().hex started = time.perf_counter() status = 500 sent = False async def send_wrapper(message): nonlocal status, sent if message["type"] == "http.response.start": status = int(message.get("status", 500)) headers = MutableHeaders(scope=message) headers["X-Request-ID"] = request_id if message["type"] == "http.response.body" and not message.get("more_body", False) and not sent: sent = True elapsed_ms = (time.perf_counter() - started) * 1000 logger.info("request id=%s method=%s path=%s status=%s duration_ms=%.1f", request_id, scope.get("method", ""), path, status, elapsed_ms) await send(message) try: await self.app(scope, receive, send_wrapper) except Exception: if not sent: logger.info("request id=%s method=%s path=%s status=500 duration_ms=%.1f", request_id, scope.get("method", ""), path, (time.perf_counter() - started) * 1000) raise app.add_middleware(RequestCorrelationMiddleware) # ── In-memory log buffer handler ────────────────────────────────────────────── _buf_handler = _BufferHandler() _buf_handler.setLevel(logging.DEBUG) logging.getLogger().addHandler(_buf_handler) # ── File log handler (rotates at 5 MB, keeps 3 files) ──────────────────────── _LOG_DIR = Path(os.environ.get("LOG_DIR", "/logs")) try: _LOG_DIR.mkdir(parents=True, exist_ok=True) _file_handler = RotatingFileHandler( _LOG_DIR / "app.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8", ) _file_handler.setLevel(logging.INFO) _file_handler.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-8s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )) logging.getLogger().addHandler(_file_handler) except Exception as _log_err: logger.warning("Could not set up file logging at %s: %s", _LOG_DIR, _log_err) # ── Static-asset caching middleware ────────────────────────────────────────── # JS and CSS are served with ?v= by loader.js — safe to cache for 1 year. # Section HTML uses ?v= (always fresh) so no long-term cache there. # # Pure ASGI middleware, not @app.middleware("http") (= Starlette's # BaseHTTPMiddleware) — that implementation wraps every request in a task # group around call_next(), and a client disconnecting mid-StreamingResponse # (e.g. the live-attribution SSE stream hitting its idle timeout) races that # task group and raises "RuntimeError: No response returned." This class # only touches response headers via the raw `send` callable, so it never # holds the response open the way call_next() does. class StaticCacheHeadersMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return path = scope["path"] has_version = "v" in parse_qs((scope.get("query_string") or b"").decode()) async def send_wrapper(message): if message["type"] == "http.response.start": headers = MutableHeaders(scope=message) if has_version and (path.startswith("/static/js/") or path.endswith(".css")): headers["Cache-Control"] = "public, max-age=31536000, immutable" elif path.startswith("/static/sections/"): headers["Cache-Control"] = "no-store" elif path.startswith("/static/vendor/"): headers["Cache-Control"] = "public, max-age=86400" elif path.startswith("/static/") and not has_version: # Bootstrap files (style.css, loader.js, nav.js, …) are loaded without a ?v= query. # Revalidate on every load so GUI edits appear after a normal reload — the server # returns 304 when the file is unchanged, so this stays cheap. headers["Cache-Control"] = "no-cache" await send(message) await self.app(scope, receive, send_wrapper) app.add_middleware(StaticCacheHeadersMiddleware) # ── API-key gate for non-browser callers ──────────────────────────────────── # The app's own UI calls /api/* same-origin from the browser and needs no # key — everything else (curl, scripts, MCP clients, agents) does. Gated by # Origin/Referer host matching the request's own Host header, which the # browser sets automatically and a bare script/curl call generally doesn't. # /mcp always requires the key regardless of origin, since no in-app browser # code calls it — it exists specifically for external MCP clients. # Raw ASGI (not @app.middleware("http")/BaseHTTPMiddleware) for the same # reason as StaticCacheHeadersMiddleware above: that wrapper's task-group # around call_next() fights with a client disconnecting mid-SSE-stream. class ApiKeyGateMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return path = scope["path"] if not (path.startswith("/api/") or path == "/mcp"): await self.app(scope, receive, send) return if not _REQUIRE_EXTERNAL_API_KEY and not _load_settings().get("external_api_key_required"): # Explicit opt-out is intended only for an isolated deployment # protected by another authentication layer. Same-origin browser # requests still bypass the external caller gate below. await self.app(scope, receive, send) return headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} host = headers.get("host", "") def _same_origin(url: str) -> bool: if not url or not host: return False try: from urllib.parse import urlparse return urlparse(url).netloc == host except Exception: return False is_browser_same_origin = path != "/mcp" and ( headers.get("sec-fetch-site", "").lower() == "same-origin" or _same_origin(headers.get("origin", "")) or _same_origin(headers.get("referer", "")) ) if is_browser_same_origin: await self.app(scope, receive, send) return expected = _ensure_external_api_key() provided = headers.get("x-api-key", "") if not provided and headers.get("authorization", "").lower().startswith("bearer "): provided = headers["authorization"][7:] if provided and provided == expected: await self.app(scope, receive, send) return import json as _json body = _json.dumps({"detail": "Missing or invalid API key — pass it as X-API-Key. Find/regenerate it in Settings > API Keys > External API Access."}).encode() await send({ "type": "http.response.start", "status": 401, "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())], }) await send({"type": "http.response.body", "body": body}) app.add_middleware(ApiKeyGateMiddleware) # ── Routers ─────────────────────────────────────────────────────────────────── app.include_router(admin.router) app.include_router(settings.router) app.include_router(library.router) app.include_router(stt.router) app.include_router(sources.router) app.include_router(docker.router) app.include_router(tts.router) app.include_router(conversation.router) app.include_router(reader.router) app.include_router(characters.router) app.include_router(rehearsals_db.router) app.include_router(recovery.router) app.include_router(history.router) app.include_router(jobs.router) app.include_router(magazine.router) app.include_router(magazine_sources.router) # ── Static files ────────────────────────────────────────────────────────────── app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # ── Dev runner ──────────────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=7890, log_level="info")