In-memory AdmissionController (idle/reserved/running, FIFO tickets keyed by
the session cookie, reservation and heartbeat expiry, promotion after
cleanup, EMA wait estimate) behind POST /api/v1/queue/join,
GET /api/v1/queue/status and DELETE /api/v1/queue/ticket. POST /api/v1/query
claims the session's reservation first and joins implicitly when idle.
Settings: CONFLUENCE_WEB_QUEUE_{RESERVATION_SECONDS,HEARTBEAT_SECONDS,MAX_LENGTH}.
619 lines
24 KiB
Python
619 lines
24 KiB
Python
"""FastAPI application factory, endpoints, session handling, and security headers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import secrets
|
|
import time
|
|
import urllib.parse
|
|
from typing import Any, Callable, Dict, Optional, Tuple
|
|
|
|
from fastapi import FastAPI, HTTPException, Request, Response
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel, ConfigDict
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from backend.admission import AdmissionController
|
|
from backend.artifacts import ArtifactStore
|
|
from backend.confluence import ConfluenceClient
|
|
from backend.containers import (
|
|
ContainerManager,
|
|
DockerContainerManager,
|
|
FakeContainerManager,
|
|
)
|
|
from backend.errors import (
|
|
AppError,
|
|
ArtifactNotFoundError,
|
|
BusyError,
|
|
InvalidInputError,
|
|
OriginDeniedError,
|
|
ConnectivityFailedError,
|
|
sanitize_message,
|
|
RequestTooLargeError,
|
|
TicketNotFoundError,
|
|
)
|
|
from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter
|
|
from backend.runner import QueryRunner
|
|
from backend.settings import Settings, validate_confluence_url
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SESSION_COOKIE_NAME = "cw_session"
|
|
|
|
CSP_POLICY = (
|
|
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; "
|
|
"img-src 'self'; media-src 'none'; font-src 'self'; object-src 'none'; "
|
|
"frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
)
|
|
|
|
FRONTEND_ASSET_DIRS = ("css", "js", "vendor", "assets")
|
|
|
|
MAX_VERIFY_BODY_BYTES = 128 * 1024
|
|
MAX_QUERY_BODY_BYTES = 6 * 16 * 1024 * 1024 + 64 * 1024
|
|
|
|
|
|
class AuthVerifyRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
url: str
|
|
pat: str
|
|
|
|
|
|
class QueryCredentials(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
url: str
|
|
pat: str
|
|
|
|
|
|
class QueryRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
prompt: str
|
|
credentials: QueryCredentials
|
|
|
|
|
|
class QueueJoinRequest(BaseModel):
|
|
"""Body is `{}`. extra="forbid" rejects any field, including credentials/pat."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class ServerSessionStore:
|
|
"""In-memory store of server-issued session IDs to prevent client-forged sessions."""
|
|
|
|
def __init__(self) -> None:
|
|
self._sessions: Dict[str, float] = {}
|
|
|
|
def is_valid(self, session_id: Optional[str]) -> bool:
|
|
if not session_id or not isinstance(session_id, str):
|
|
return False
|
|
if len(session_id) != 64: # secrets.token_hex(32) is 64 hex characters
|
|
return False
|
|
return session_id in self._sessions
|
|
|
|
def create_session(self) -> str:
|
|
session_id = secrets.token_hex(32)
|
|
self._sessions[session_id] = time.time()
|
|
return session_id
|
|
|
|
def touch(self, session_id: str) -> None:
|
|
if session_id in self._sessions:
|
|
self._sessions[session_id] = time.time()
|
|
|
|
def prune_stale(self, max_age_seconds: float = 86400.0) -> None:
|
|
now = time.time()
|
|
stale = [s for s, ts in self._sessions.items() if now - ts > max_age_seconds]
|
|
for s in stale:
|
|
self._sessions.pop(s, None)
|
|
|
|
|
|
def get_or_set_session_cookie(
|
|
request: Request, response: Response, session_store: ServerSessionStore
|
|
) -> str:
|
|
"""Read server-issued cw_session cookie or issue a new cryptographically random session ID."""
|
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if session_store.is_valid(session_id):
|
|
session_store.touch(session_id) # type: ignore[arg-type]
|
|
else:
|
|
session_id = session_store.create_session()
|
|
|
|
secure = request.url.scheme == "https"
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=session_id,
|
|
httponly=True,
|
|
samesite="strict",
|
|
path="/",
|
|
secure=secure,
|
|
)
|
|
return session_id
|
|
|
|
|
|
def _normalize_endpoint(scheme: str, hostname: Optional[str], port: Optional[int]) -> Tuple[str, str, int]:
|
|
s = scheme.lower()
|
|
h = (hostname or "").lower()
|
|
if port is not None:
|
|
p = port
|
|
else:
|
|
p = 443 if s == "https" else (80 if s == "http" else 0)
|
|
return (s, h, p)
|
|
|
|
|
|
def check_same_origin(request: Request) -> None:
|
|
"""Require matching Origin for state-changing POST requests."""
|
|
origin = request.headers.get("origin")
|
|
if not origin:
|
|
raise OriginDeniedError("Missing Origin header")
|
|
|
|
try:
|
|
parsed_origin = urllib.parse.urlsplit(origin)
|
|
except ValueError:
|
|
raise OriginDeniedError("Malformed Origin header")
|
|
if parsed_origin.scheme not in ("http", "https") or not parsed_origin.netloc or parsed_origin.username is not None or parsed_origin.password is not None or parsed_origin.path or parsed_origin.query or parsed_origin.fragment:
|
|
raise OriginDeniedError("Malformed Origin header")
|
|
|
|
try:
|
|
origin_norm = _normalize_endpoint(
|
|
parsed_origin.scheme,
|
|
parsed_origin.hostname,
|
|
parsed_origin.port,
|
|
)
|
|
req_norm = _normalize_endpoint(
|
|
request.url.scheme,
|
|
request.url.hostname,
|
|
request.url.port,
|
|
)
|
|
except ValueError:
|
|
raise OriginDeniedError("Invalid origin or destination")
|
|
|
|
if origin_norm != req_norm:
|
|
raise OriginDeniedError("Origin does not match request destination")
|
|
|
|
|
|
async def _periodic_maintenance(runner, store, session_store, admission) -> None:
|
|
while True:
|
|
await asyncio.sleep(60)
|
|
if not runner._gate.locked():
|
|
try:
|
|
await runner.reconcile()
|
|
except Exception:
|
|
logger.warning("Container reconciliation failed; query admission remains closed")
|
|
try:
|
|
await asyncio.to_thread(store.expire_artifacts)
|
|
session_store.prune_stale()
|
|
admission.expire()
|
|
except Exception:
|
|
logger.warning("Artifact or session maintenance failed")
|
|
|
|
|
|
class SecurityMiddleware:
|
|
"""ASGI middleware bounds incoming bytes without stealing disconnect events."""
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
return await self.app(scope, receive, send)
|
|
request = Request(scope)
|
|
limit = MAX_VERIFY_BODY_BYTES if scope["path"] == "/api/v1/auth/verify" else MAX_QUERY_BODY_BYTES
|
|
total = 0
|
|
exceeded = False
|
|
|
|
async def bounded_receive():
|
|
nonlocal total, exceeded
|
|
message = await receive()
|
|
if message["type"] == "http.request":
|
|
total += len(message.get("body", b""))
|
|
if total > limit:
|
|
exceeded = True
|
|
raise RequestTooLargeError("Request body exceeds byte limit")
|
|
return message
|
|
|
|
async def secure_send(message):
|
|
if message["type"] == "http.response.start":
|
|
headers = list(message.get("headers", []))
|
|
if scope["path"].startswith("/api/v1/"):
|
|
headers = [(k, v) for k, v in headers if k.lower() != b"cache-control"]
|
|
headers.append((b"cache-control", b"no-store"))
|
|
else:
|
|
headers.extend([(b"content-security-policy", CSP_POLICY.encode()), (b"referrer-policy", b"no-referrer")])
|
|
if not any(k.lower() == b"x-content-type-options" for k, _ in headers):
|
|
headers.append((b"x-content-type-options", b"nosniff"))
|
|
message = {**message, "headers": headers}
|
|
await send(message)
|
|
|
|
if scope["method"] == "POST":
|
|
header = request.headers.get("content-length")
|
|
if header is not None:
|
|
try:
|
|
length = int(header)
|
|
if length < 0:
|
|
raise ValueError
|
|
except ValueError:
|
|
return await JSONResponse(status_code=400, content=InvalidInputError("Invalid Content-Length").to_envelope())(scope, receive, secure_send)
|
|
if length > limit:
|
|
return await JSONResponse(status_code=413, content=RequestTooLargeError().to_envelope())(scope, receive, secure_send)
|
|
# Bound before framework parsing: Starlette otherwise translates read exceptions to HTTP 400.
|
|
chunks = bytearray()
|
|
while True:
|
|
try:
|
|
message = await bounded_receive()
|
|
except RequestTooLargeError as exc:
|
|
return await JSONResponse(status_code=413, content=exc.to_envelope())(scope, receive, secure_send)
|
|
if message["type"] == "http.disconnect":
|
|
return
|
|
chunks.extend(message.get("body", b""))
|
|
if not message.get("more_body", False):
|
|
break
|
|
delivered = False
|
|
async def replay_receive():
|
|
nonlocal delivered
|
|
if not delivered:
|
|
delivered = True
|
|
return {"type": "http.request", "body": bytes(chunks), "more_body": False}
|
|
return await receive()
|
|
return await self.app(scope, replay_receive, secure_send)
|
|
return await self.app(scope, receive, secure_send)
|
|
|
|
|
|
class OwnedFileResponse(FileResponse):
|
|
def __init__(self, *args, store, artifact_id, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.store, self.artifact_id = store, artifact_id
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
try:
|
|
return await super().__call__(scope, receive, send)
|
|
finally:
|
|
await asyncio.to_thread(self.store.release_reader, self.artifact_id)
|
|
|
|
|
|
def create_app(
|
|
settings: Optional[Settings] = None,
|
|
container_manager: Optional[ContainerManager] = None,
|
|
artifact_store: Optional[ArtifactStore] = None,
|
|
model_adapter: Optional[ModelAdapter] = None,
|
|
confluence_client_factory: Optional[Callable[..., ConfluenceClient]] = None,
|
|
admission_controller: Optional[AdmissionController] = None,
|
|
) -> FastAPI:
|
|
"""Create and configure FastAPI application with dependency injection."""
|
|
app_settings = settings or Settings.from_env()
|
|
store = artifact_store or ArtifactStore(app_settings.artifact_storage_dir)
|
|
|
|
# Container manager selection
|
|
if container_manager is not None:
|
|
mgr = container_manager
|
|
elif app_settings.dev_mode:
|
|
mgr = FakeContainerManager()
|
|
else:
|
|
mgr = DockerContainerManager(app_settings)
|
|
|
|
# Model adapter selection
|
|
if model_adapter is not None:
|
|
adapter = model_adapter
|
|
elif app_settings.dev_mode or app_settings.model_provider == "fake":
|
|
adapter = FakeModelAdapter()
|
|
elif app_settings.model_provider == "openai":
|
|
if not app_settings.model_api_key or not app_settings.model_api_key.strip():
|
|
raise RuntimeError("Missing model_api_key for OpenAI provider in production mode")
|
|
adapter = OpenAIModelAdapter(
|
|
api_key=app_settings.model_api_key,
|
|
model_name=app_settings.model_name,
|
|
endpoint=app_settings.model_endpoint,
|
|
context_window_tokens=app_settings.model_context_window_tokens,
|
|
max_output_tokens=app_settings.model_max_output_tokens,
|
|
timeout=app_settings.model_timeout_seconds,
|
|
)
|
|
else:
|
|
raise RuntimeError(f"Unknown model_provider '{app_settings.model_provider}' in production mode")
|
|
|
|
if confluence_client_factory is None and app_settings.dev_mode:
|
|
from backend.dev.fake_confluence import create_client
|
|
confluence_client_factory = create_client
|
|
|
|
runner = QueryRunner(
|
|
settings=app_settings,
|
|
container_manager=mgr,
|
|
artifact_store=store,
|
|
model_adapter=adapter,
|
|
confluence_client_factory=confluence_client_factory,
|
|
)
|
|
|
|
session_store = ServerSessionStore()
|
|
admission = admission_controller or AdmissionController(
|
|
reservation_seconds=app_settings.queue_reservation_seconds,
|
|
heartbeat_seconds=app_settings.queue_heartbeat_seconds,
|
|
max_queue_length=app_settings.queue_max_length,
|
|
)
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def lifespan(fastapi_app: FastAPI):
|
|
maintenance_task = None
|
|
try:
|
|
await asyncio.to_thread(store.purge_all)
|
|
if isinstance(mgr, DockerContainerManager):
|
|
await mgr.verify_rootless()
|
|
await runner.reconcile()
|
|
maintenance_task = asyncio.create_task(_periodic_maintenance(runner, store, session_store, admission))
|
|
yield
|
|
finally:
|
|
if maintenance_task is not None:
|
|
maintenance_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await maintenance_task
|
|
await adapter.close()
|
|
|
|
app = FastAPI(title="Confluence Web Backend", docs_url=None, redoc_url=None, lifespan=lifespan)
|
|
|
|
# Bounded concurrency semaphore for auth verify
|
|
verify_semaphore = asyncio.Semaphore(10)
|
|
|
|
# Error envelope exception handlers
|
|
@app.exception_handler(AppError)
|
|
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=exc.to_envelope(),
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
# Sanitize framework errors: NEVER echo back request body or PAT
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": {"code": "invalid_input", "message": "Invalid request payload"}},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: Any) -> JSONResponse:
|
|
code_map = {
|
|
400: "invalid_input",
|
|
403: "origin_denied",
|
|
404: "artifact_not_found",
|
|
405: "invalid_input",
|
|
409: "busy",
|
|
413: "request_too_large",
|
|
502: "upstream_failed",
|
|
504: "query_timeout",
|
|
500: "execution_failed",
|
|
}
|
|
if exc.status_code == 404:
|
|
if request.url.path.startswith("/api/v1/artifacts/"):
|
|
code = "artifact_not_found"
|
|
else:
|
|
code = "invalid_input"
|
|
elif exc.status_code == 405:
|
|
code = "invalid_input"
|
|
else:
|
|
code = code_map.get(exc.status_code, "execution_failed")
|
|
msg = str(exc.detail) if exc.detail else "HTTP error"
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"error": {"code": code, "message": sanitize_message(msg)}},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"error": {"code": "execution_failed", "message": "Internal execution failure"}},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
app.add_middleware(SecurityMiddleware)
|
|
app.state.runner = runner
|
|
app.state.artifact_store = store
|
|
app.state.admission = admission
|
|
|
|
@app.get("/")
|
|
async def root_index(request: Request):
|
|
if app_settings.frontend_dist_dir and (app_settings.frontend_dist_dir / "index.html").exists():
|
|
index_path = app_settings.frontend_dist_dir / "index.html"
|
|
html_content = await asyncio.to_thread(index_path.read_text, encoding="utf-8")
|
|
resp = HTMLResponse(content=html_content, status_code=200)
|
|
else:
|
|
resp = JSONResponse(content={"status": "ok", "service": "confluence-web-backend"})
|
|
get_or_set_session_cookie(request, resp, session_store)
|
|
return resp
|
|
|
|
@app.post("/api/v1/auth/verify")
|
|
async def verify_auth(req: AuthVerifyRequest, request: Request):
|
|
check_same_origin(request)
|
|
|
|
if len(req.pat.encode("utf-8")) > 8192 or not req.pat.strip():
|
|
raise InvalidInputError("Invalid Confluence PAT")
|
|
try:
|
|
async with asyncio.timeout(15), verify_semaphore:
|
|
# Validate Confluence URL against approved origins
|
|
canonical_url = validate_confluence_url(req.url, app_settings.approved_confluence_origins)
|
|
|
|
# Request-scoped verification client
|
|
client_factory = confluence_client_factory or ConfluenceClient
|
|
client_kwargs: Dict[str, Any] = {}
|
|
if app_settings.confluence_proxy:
|
|
client_kwargs["proxy"] = app_settings.confluence_proxy
|
|
async with client_factory(
|
|
base_url=canonical_url,
|
|
pat=req.pat,
|
|
approved_origins=app_settings.approved_confluence_origins,
|
|
corporate_ca_path=app_settings.corporate_ca_path,
|
|
timeout=15.0,
|
|
**client_kwargs,
|
|
) as client:
|
|
await client.verify_auth()
|
|
except asyncio.TimeoutError as exc:
|
|
raise ConnectivityFailedError("Confluence verification timed out") from exc
|
|
|
|
resp = JSONResponse(content={"valid": True})
|
|
get_or_set_session_cookie(request, resp, session_store)
|
|
return resp
|
|
|
|
@app.post("/api/v1/queue/join")
|
|
async def queue_join(req: QueueJoinRequest, request: Request):
|
|
check_same_origin(request)
|
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if not session_store.is_valid(session_id):
|
|
session_id = session_store.create_session()
|
|
else:
|
|
session_store.touch(session_id) # type: ignore[arg-type]
|
|
|
|
view = admission.join(session_id) # type: ignore[arg-type]
|
|
resp = JSONResponse(content=view)
|
|
secure = request.url.scheme == "https"
|
|
resp.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=session_id,
|
|
httponly=True,
|
|
samesite="strict",
|
|
path="/",
|
|
secure=secure,
|
|
)
|
|
return resp
|
|
|
|
@app.get("/api/v1/queue/status")
|
|
async def queue_status(request: Request):
|
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if not session_store.is_valid(session_id):
|
|
raise TicketNotFoundError("No active queue ticket for this session")
|
|
view = admission.status(session_id) # type: ignore[arg-type]
|
|
return JSONResponse(content=view)
|
|
|
|
@app.delete("/api/v1/queue/ticket")
|
|
async def queue_leave(request: Request):
|
|
check_same_origin(request)
|
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if session_store.is_valid(session_id):
|
|
admission.leave(session_id) # type: ignore[arg-type]
|
|
return Response(status_code=204)
|
|
|
|
@app.post("/api/v1/query")
|
|
async def execute_query(req: QueryRequest, request: Request):
|
|
check_same_origin(request)
|
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if not session_store.is_valid(session_id):
|
|
session_id = session_store.create_session()
|
|
else:
|
|
session_store.touch(session_id) # type: ignore[arg-type]
|
|
|
|
# Admission: claim the reservation (or implicit-join while idle) before
|
|
# any work starts. The runner's own gate remains a last line of defence.
|
|
if not admission.claim(session_id):
|
|
raise BusyError("Another session holds the reservation")
|
|
|
|
run_started = admission.clock()
|
|
result: Optional[Dict[str, Any]] = None
|
|
cancelled = False
|
|
try:
|
|
result = await runner.run(
|
|
prompt=req.prompt,
|
|
confluence_url=req.credentials.url,
|
|
confluence_pat=req.credentials.pat,
|
|
session_id=session_id,
|
|
is_disconnected=request.is_disconnected,
|
|
)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
raise
|
|
finally:
|
|
# Run end (success, failure, timeout, cancellation, or disconnect):
|
|
# record duration and promote the next ticket after the runner's
|
|
# own cleanup budget above has already completed.
|
|
duration = result["duration_seconds"] if result is not None else (admission.clock() - run_started)
|
|
admission.run_end(session_id, duration_seconds=duration, cancelled=cancelled)
|
|
|
|
resp = JSONResponse(content=result)
|
|
# Ensure session cookie is set
|
|
secure = request.url.scheme == "https"
|
|
resp.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=session_id,
|
|
httponly=True,
|
|
samesite="strict",
|
|
path="/",
|
|
secure=secure,
|
|
)
|
|
return resp
|
|
|
|
@app.get("/api/v1/artifacts/{artifact_id}")
|
|
async def download_artifact(artifact_id: str, request: Request):
|
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if not session_store.is_valid(session_id):
|
|
raise ArtifactNotFoundError("Artifact not found")
|
|
|
|
lookup_task = asyncio.create_task(asyncio.to_thread(store.get_artifact_for_download, artifact_id, session_id=session_id))
|
|
cancelled = False
|
|
while not lookup_task.done():
|
|
try:
|
|
await asyncio.shield(lookup_task)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
res = lookup_task.result()
|
|
if cancelled:
|
|
if res:
|
|
await asyncio.to_thread(store.release_reader, artifact_id)
|
|
raise asyncio.CancelledError
|
|
if not res:
|
|
raise ArtifactNotFoundError("Artifact not found") # identical to unknown-ID and no-session responses
|
|
|
|
file_path, display_name, size_bytes = res
|
|
|
|
# RFC 6266 filename encoding
|
|
ascii_fallback = "".join(c if 0x20 <= ord(c) < 0x7F and c not in '"\\;' else '_' for c in display_name)
|
|
if not ascii_fallback.strip("_ "):
|
|
ascii_fallback = "artifact.bin"
|
|
|
|
encoded_name = urllib.parse.quote(display_name, encoding="utf-8")
|
|
content_disposition = f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{encoded_name}'
|
|
|
|
headers = {
|
|
"Content-Disposition": content_disposition,
|
|
"Content-Type": "application/octet-stream",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Cache-Control": "no-store",
|
|
}
|
|
return OwnedFileResponse(
|
|
store=store, artifact_id=artifact_id,
|
|
path=str(file_path),
|
|
headers=headers,
|
|
)
|
|
|
|
# Serve only the production frontend asset directories at the root origin.
|
|
# index.html references css/, js/, vendor/ and assets/ relatively; dev/, tests/ and
|
|
# documentation inside the frontend tree are never exposed.
|
|
if app_settings.frontend_dist_dir and app_settings.frontend_dist_dir.exists():
|
|
for asset_dir in FRONTEND_ASSET_DIRS:
|
|
directory = app_settings.frontend_dist_dir / asset_dir
|
|
if directory.is_dir():
|
|
app.mount(f"/{asset_dir}", StaticFiles(directory=str(directory)), name=f"frontend-{asset_dir}")
|
|
|
|
return app
|
|
|
|
|
|
_default_app: Optional[FastAPI] = None
|
|
|
|
|
|
def get_app() -> FastAPI:
|
|
"""Lazy default application instance avoiding module-import side-effects."""
|
|
global _default_app
|
|
if _default_app is None:
|
|
_default_app = create_app()
|
|
return _default_app
|
|
|
|
|
|
async def app(scope: Any, receive: Any, send: Any) -> None:
|
|
"""ASGI 3.0 entrypoint for uvicorn backend.app:app."""
|
|
application = get_app()
|
|
await application(scope, receive, send)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
app_settings = Settings.from_env()
|
|
uvicorn.run("backend.app:app", host=app_settings.bind_host, port=app_settings.bind_port)
|