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 # 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, 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) # 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)