The origin check is an exact match including the context path, so users had to type "https://collab.lge.com/main" precisely. GET /api/v1/config now returns the approved origins in canonical form (non-secret: they are the only destinations the backend will talk to), and the UI swaps the URL text field for a select listing them, keeping the element id, focus handling and the Test connection flow unchanged. The text field remains the fallback when the fetch fails. Backend validation of the submitted URL is untouched. Mock server serves the endpoint; contract, API and e2e tests cover it.
637 lines
24 KiB
Python
637 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, canonicalize_url, 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.get("/api/v1/config")
|
|
async def public_config():
|
|
"""Non-secret deployment facts the UI needs before any credentials exist.
|
|
|
|
The approved Confluence origins are the destinations this backend will talk
|
|
to; the UI offers them as a fixed choice so users cannot mistype the origin or
|
|
its context path. Validation of the submitted URL is unchanged.
|
|
"""
|
|
origins: list[str] = []
|
|
for origin in app_settings.approved_confluence_origins:
|
|
try:
|
|
canonical = canonicalize_url(origin)
|
|
except Exception:
|
|
continue
|
|
if canonical not in origins:
|
|
origins.append(canonical)
|
|
return JSONResponse(content={"approved_origins": origins})
|
|
|
|
@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)
|