init
This commit is contained in:
192
api/setup.py
Normal file
192
api/setup.py
Normal file
@@ -0,0 +1,192 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user