import os from typing import Optional from urllib.parse import quote_plus from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from services.config_service import ConfigService router = APIRouter(prefix="/api/setup", tags=["setup"]) PASSWORD_PLACEHOLDER = "********" # --------------------------------------------------------------------------- # Request / response models # --------------------------------------------------------------------------- class DatabaseConfig(BaseModel): host: str port: int = 3306 user: str = "root" password: str = "" database: str = "dialectica" class KeycloakConfig(BaseModel): host: str = "" realm: str = "" client_id: str = "" class TlsConfig(BaseModel): cert_path: str = "" key_path: str = "" force_https: bool = False class FullConfig(BaseModel): database: Optional[DatabaseConfig] = None keycloak: Optional[KeycloakConfig] = None tls: Optional[TlsConfig] = None # --------------------------------------------------------------------------- # Access control dependency # --------------------------------------------------------------------------- async def setup_guard(request: Request): """Three-phase access control for setup routes. 1. Not initialized → only localhost allowed 2. ENV_MODE=dev → open 3. ENV_MODE=prod → Keycloak admin JWT required """ config = ConfigService.load() initialized = config.get("initialized", False) env_mode = os.getenv("ENV_MODE", "dev") if env_mode == "dev": return # dev mode: no auth needed, even before initialisation if not initialized: # prod + not initialised: only localhost may configure client_ip = request.client.host if client_ip not in ("127.0.0.1", "::1"): raise HTTPException( status_code=403, detail="初次设置仅允许从本机访问", ) return # prod → delegate to Keycloak middleware (Phase 3) from app.middleware.auth import require_admin await require_admin(request, config) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @router.get("/status") async def setup_status(): """Return current system initialisation state, including KC info for OIDC.""" config = ConfigService.load() env_mode = os.getenv("ENV_MODE", "dev") result = { "initialized": config.get("initialized", False), "env_mode": env_mode, "db_configured": ConfigService.is_db_configured(), } # Include Keycloak info so the frontend can build OIDC config kc = config.get("keycloak", {}) if env_mode == "prod" and kc.get("host") and kc.get("realm"): result["keycloak"] = { "authority": f"{kc['host']}/realms/{kc['realm']}", "client_id": kc.get("client_id", ""), } return result @router.get("/config", dependencies=[Depends(setup_guard)]) async def get_config(): """Return full config with passwords replaced by placeholder.""" config = ConfigService.load() if "database" in config and config["database"].get("password"): config["database"]["password"] = PASSWORD_PLACEHOLDER return config @router.put("/config", dependencies=[Depends(setup_guard)]) async def update_config(payload: FullConfig): """Merge submitted config sections into the YAML file.""" config = ConfigService.load() if payload.database is not None: db_dict = payload.database.model_dump() # If password is the placeholder, keep the existing real password if db_dict.get("password") == PASSWORD_PLACEHOLDER: db_dict["password"] = config.get("database", {}).get("password", "") config["database"] = db_dict if payload.keycloak is not None: config["keycloak"] = payload.keycloak.model_dump() if payload.tls is not None: config["tls"] = payload.tls.model_dump() ConfigService.save(config) return {"message": "配置已保存"} @router.post("/test-db", dependencies=[Depends(setup_guard)]) async def test_db_connection(db_config: DatabaseConfig): """Test a database connection with the provided parameters (no save).""" from sqlalchemy import create_engine, text password = db_config.password # If password is the placeholder, use the real password from config if password == PASSWORD_PLACEHOLDER: password = ConfigService.load().get("database", {}).get("password", "") url = ( f"mysql+pymysql://{quote_plus(db_config.user)}:{quote_plus(password)}" f"@{db_config.host}:{db_config.port}/{db_config.database}" ) try: engine = create_engine(url, pool_pre_ping=True) with engine.connect() as conn: conn.execute(text("SELECT 1")) engine.dispose() return {"success": True, "message": "数据库连接成功"} except Exception as e: return {"success": False, "message": str(e)} @router.post("/test-keycloak", dependencies=[Depends(setup_guard)]) async def test_keycloak(kc_config: KeycloakConfig): """Test Keycloak connectivity by fetching the OIDC discovery document.""" import httpx well_known = ( f"{kc_config.host}/realms/{kc_config.realm}" f"/.well-known/openid-configuration" ) try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(well_known) if resp.status_code == 200: return {"success": True, "message": "Keycloak 连通正常"} return {"success": False, "message": f"HTTP {resp.status_code}"} except Exception as e: return {"success": False, "message": str(e)} @router.post("/initialize", dependencies=[Depends(setup_guard)]) async def initialize(): """Mark system as initialised and reload DB connection.""" config = ConfigService.load() if not ConfigService.is_db_configured(): raise HTTPException(status_code=400, detail="请先配置数据库连接") # Reload DB engine so business routes can start working from app.db_models import reload_db_connection from app.storage.database import init_db reload_db_connection() init_db() config["initialized"] = True ConfigService.save(config) return {"message": "系统初始化完成", "initialized": True}