Files
computer/cptr/app.py
T
Timothy Jaeryang Baek 7938f73e57 refac
2026-06-14 21:26:40 +02:00

356 lines
11 KiB
Python

import time
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from cptr.routers import (
admin_router,
audio_router,
auth_router,
automations_router,
bridge_router,
webhook_router,
chat_router,
events_router,
files_router,
gateway_router,
git_router,
proxy_router,
search_router,
skills_router,
state_router,
terminal_router,
workspace_router,
)
from cptr.utils.config import check_access, load_config
from cptr.utils.db import init_db
START_TIME = time.time()
app = FastAPI()
@app.on_event("startup")
async def startup():
# Use OS certificate store (Windows CertStore, macOS Keychain, etc.)
# instead of the bundled certifi CA bundle — fixes #31.
import logging as _logging
import truststore
truststore.inject_into_ssl()
_logging.getLogger(__name__).info("truststore: using system certificate store")
await init_db()
from cptr.env import STARTUP_TOKEN
app.state.startup_token = STARTUP_TOKEN
# Reconcile stuck chat state from prior crash/restart
from cptr.env import ENABLE_CHAT_RECONCILE_ON_STARTUP
if ENABLE_CHAT_RECONCILE_ON_STARTUP:
from cptr.utils.chat_task import reconcile_chat_state
await reconcile_chat_state()
# Start automation scheduler
import asyncio
from cptr.utils.automations import scheduler_worker_loop
asyncio.create_task(scheduler_worker_loop(app))
# Start messaging bots
from cptr.utils.bridge import BotManager
app.state.bot_manager = BotManager()
asyncio.create_task(app.state.bot_manager.start_all())
@app.on_event("shutdown")
async def shutdown():
bot_manager = getattr(app.state, "bot_manager", None)
if bot_manager:
await bot_manager.stop_all()
# Clean up browser sessions and launched Chrome
try:
from cptr.utils.browser.session import session_manager
from cptr.utils.browser.launcher import shutdown_browser
await session_manager.close_all()
await shutdown_browser()
except Exception:
pass
# Clean up stdio MCP server processes
try:
from cptr.utils.mcp.stdio_manager import stdio_manager
await stdio_manager.disconnect_all()
except Exception:
pass
# Auth middleware
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
# Skip auth for: auth endpoints, health, static assets, HTML pages
if (
path.startswith("/api/auth")
or path == "/api/health"
or path == "/api/config"
or path == "/api/changelog"
or path == "/manifest.json"
):
return await call_next(request)
if path.startswith("/_app/") or path.startswith("/v1/") or not path.startswith("/api/"):
return await call_next(request)
# GET /api/files/{id} is public (UUID is unguessable, <img src> can't send cookies)
if request.method == "GET" and path.startswith("/api/files/"):
return await call_next(request)
client_host = request.client.host if request.client else "127.0.0.1"
jwt_token = request.cookies.get("cptr_session")
# Read trusted header (configurable, defaults to Remote-User)
header_name = load_config().get("auth", {}).get("header", "Remote-User")
remote_user = request.headers.get(header_name)
auth = check_access(
client_host=client_host,
jwt_token=jwt_token,
remote_user_header=remote_user,
)
if auth is None:
return JSONResponse({"error": "unauthorized"}, 401)
request.state.auth = auth
return await call_next(request)
# Proxy fallback middleware: intercepts sub-resource requests for proxied
# dev servers (JS modules, CSS, etc.) based on the Referer header chain.
# Added after auth middleware so it wraps outermost (runs before auth),
# since proxied sub-resources don't need cptr auth.
from cptr.utils.proxy_middleware import ProxyFallbackMiddleware
app.add_middleware(ProxyFallbackMiddleware)
# CORS middleware: uses CPTR_CORS_ALLOWED_ORIGINS env var (default "*").
from fastapi.middleware.cors import CORSMiddleware
from cptr.env import CORS_ALLOWED_ORIGINS
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ALLOWED_ORIGINS if isinstance(CORS_ALLOWED_ORIGINS, list) else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Path normalization middleware (Windows: \ → / in JSON responses)
import platform
if platform.system() == "Windows":
import json as _json
@app.middleware("http")
async def normalize_paths_middleware(request: Request, call_next):
response = await call_next(request)
if response.headers.get("content-type", "").startswith("application/json") and hasattr(
response, "body"
):
try:
body = b""
async for chunk in response.body_iterator:
body += chunk if isinstance(chunk, bytes) else chunk.encode()
text = body.decode("utf-8")
# Replace backslashes that appear in JSON string values
# (JSON already escapes \ as \\, so we replace \\\\ with /)
data = _json.loads(text)
_normalize_obj(data)
new_body = _json.dumps(data, ensure_ascii=False).encode("utf-8")
from starlette.responses import Response as StarletteResponse
return StarletteResponse(
content=new_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type="application/json",
)
except Exception:
pass
return response
def _normalize_obj(obj):
"""Recursively replace backslashes with forward slashes in string values
that look like file paths (contain :\\)."""
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str) and "\\" in v:
obj[k] = v.replace("\\", "/")
elif isinstance(v, (dict, list)):
_normalize_obj(v)
elif isinstance(obj, list):
for i, v in enumerate(obj):
if isinstance(v, str) and "\\" in v:
obj[i] = v.replace("\\", "/")
elif isinstance(v, (dict, list)):
_normalize_obj(v)
@app.get("/api/config")
async def get_config():
"""Public config for the frontend."""
from cptr.utils.config import get_auth_mode, AuthMode, has_any_user
from cptr.models import Config
from importlib.metadata import version as pkg_version
mode = get_auth_mode()
needs_setup = False
signup_enabled = False
if mode == AuthMode.PASSWORD:
needs_setup = not await has_any_user()
if not needs_setup:
signup_enabled = await Config.get("auth.signup_enabled") or False
try:
version = pkg_version("cptr")
except Exception:
version = "dev"
return {
"auth_mode": mode.value if hasattr(mode, "value") else str(mode),
"needs_setup": needs_setup,
"signup_enabled": signup_enabled,
"version": version,
}
# Routers
app.include_router(admin_router)
app.include_router(audio_router)
app.include_router(auth_router)
app.include_router(automations_router)
app.include_router(bridge_router)
app.include_router(webhook_router)
app.include_router(chat_router)
app.include_router(events_router)
app.include_router(files_router)
app.include_router(gateway_router)
app.include_router(git_router)
app.include_router(proxy_router)
app.include_router(search_router)
app.include_router(skills_router)
app.include_router(state_router)
app.include_router(terminal_router)
app.include_router(workspace_router)
# Health
@app.get("/api/health")
async def health():
import os
return {"status": "ok", "uptime_seconds": int(time.time() - START_TIME), "pid": os.getpid()}
@app.get("/api/changelog")
async def get_changelog():
"""Return parsed CHANGELOG.md as structured JSON (max 5 versions)."""
from cptr.utils.changelog import CHANGELOG
return {key: CHANGELOG[key] for idx, key in enumerate(CHANGELOG) if idx < 5}
@app.get("/api/version/updates")
async def get_version_updates(request: Request):
"""Check GitHub for the latest release. Admin-only."""
from cptr.routers.admin import require_admin
from importlib.metadata import version as pkg_version
require_admin(request)
try:
current = pkg_version("cptr")
except Exception:
current = "dev"
try:
import httpx
async with httpx.AsyncClient(timeout=2) as client:
r = await client.get(
"https://api.github.com/repos/open-webui/computer/releases/latest",
headers={"Accept": "application/vnd.github+json"},
)
r.raise_for_status()
tag = r.json().get("tag_name", "")
latest = tag.lstrip("v") if tag else current
return {"current": current, "latest": latest}
except Exception:
return {"current": current, "latest": current}
# PWA manifest (backend-driven so each instance has its own identity)
@app.get("/manifest.json")
async def pwa_manifest():
import socket
from importlib.metadata import version as pkg_version
try:
hostname = socket.gethostname()
except Exception:
hostname = ""
try:
version = pkg_version("cptr")
except Exception:
version = "dev"
name = f"cptr @ {hostname}" if hostname else "cptr"
return {
"name": name,
"short_name": "cptr",
"description": f"Your computer, from anywhere. v{version}",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable",
},
],
"categories": ["developer", "productivity", "utilities"],
}
# Frontend (unchanged)
FRONTEND_BUILD_DIR = Path(__file__).parent / "frontend" / "build"
if FRONTEND_BUILD_DIR.exists():
app.mount(
"/_app", StaticFiles(directory=str(FRONTEND_BUILD_DIR / "_app")), name="frontend-assets"
)
@app.get("/{full_path:path}")
async def serve_spa(request: Request, full_path: str):
file_path = FRONTEND_BUILD_DIR / full_path
if full_path and file_path.is_file():
return FileResponse(file_path)
return FileResponse(FRONTEND_BUILD_DIR / "index.html")
# Socket.IO: wraps the entire ASGI app
from cptr.socket.main import get_asgi_app
application = get_asgi_app(app)