193 lines
6.3 KiB
Python
193 lines
6.3 KiB
Python
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}
|