mirror of
https://github.com/open-webui/computer.git
synced 2026-11-14 18:08:57 +03:00
356 lines
11 KiB
Python
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)
|