309 lines
14 KiB
Python
309 lines
14 KiB
Python
"""TTS Voice Creator - Clone and Design — FastAPI entry point (thin wrapper)."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
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
|
|
|
|
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)
|
|
|
|
# ── 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=<version> by loader.js — safe to cache for 1 year.
|
|
# Section HTML uses ?v=<timestamp> (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)
|
|
|
|
# ── 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")
|