4b5602a719
- FilesPage storeToRefs + remotePath coercion; Monaco preload; list action buttons - text_io for UTF-8/LF; install/sync EOL; check_shell_eol + check_text_eol gates - ServerFormDialog, hash login redirect, AppAuth keep-session; design docs and audits Co-authored-by: Cursor <cursoragent@cursor.com>
693 lines
24 KiB
Python
693 lines
24 KiB
Python
"""Nexus — Installation Wizard API
|
||
|
||
Runs BEFORE the main app is fully configured (no .env required).
|
||
Provides endpoints for the 5-step install wizard.
|
||
|
||
Security: All endpoints refuse to operate once .env exists or install is locked.
|
||
"""
|
||
|
||
import os
|
||
import secrets
|
||
import logging
|
||
import subprocess
|
||
from pathlib import Path
|
||
from datetime import datetime, timezone
|
||
from urllib.parse import quote_plus
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import text
|
||
from sqlalchemy.ext.asyncio import create_async_engine
|
||
|
||
from server.infrastructure.database.engine_compat import patch_aiomysql_do_ping
|
||
from server.utils.text_io import read_utf8_text, write_utf8_lf
|
||
|
||
logger = logging.getLogger("nexus.install")
|
||
|
||
router = APIRouter(prefix="/api/install", tags=["install"])
|
||
|
||
# ── Paths ──
|
||
|
||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||
ENV_FILE = ROOT_DIR / ".env"
|
||
INSTALL_LOCK = ROOT_DIR / ".install_locked"
|
||
CONFIG_DIR = ROOT_DIR / "web" / "data"
|
||
CONFIG_JSON = CONFIG_DIR / "config.json"
|
||
STATE_FILE = ROOT_DIR / ".install_state.json"
|
||
|
||
|
||
def _is_installed() -> bool:
|
||
return ENV_FILE.exists() or INSTALL_LOCK.exists()
|
||
|
||
|
||
def _is_locked() -> bool:
|
||
return INSTALL_LOCK.exists()
|
||
|
||
|
||
# ── Pydantic Models ──
|
||
|
||
class InitDbRequest(BaseModel):
|
||
db_host: str = "localhost"
|
||
db_port: str = "3306"
|
||
db_name: str = "Nexus"
|
||
db_user: str = "Nexus"
|
||
db_pass: str
|
||
redis_host: str = "127.0.0.1"
|
||
redis_port: str = "6379"
|
||
redis_db: str = "0"
|
||
redis_pass: str = ""
|
||
api_port: str = "8600"
|
||
timezone: str = "Asia/Shanghai"
|
||
site_url: str = ""
|
||
|
||
|
||
class CreateAdminRequest(BaseModel):
|
||
db_host: str = "localhost"
|
||
db_port: str = "3306"
|
||
db_name: str = "Nexus"
|
||
db_user: str = "Nexus"
|
||
db_pass: str
|
||
admin_username: str = "admin"
|
||
admin_password: str
|
||
admin_email: str = ""
|
||
system_name: str = "Nexus"
|
||
system_title: str = "Nexus — 服务器运维管理平台"
|
||
|
||
|
||
# ── Helpers ──
|
||
|
||
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
|
||
return (
|
||
f"mysql+aiomysql://{req.db_user}:{quote_plus(req.db_pass)}"
|
||
f"@{req.db_host}:{req.db_port}/{req.db_name}"
|
||
)
|
||
|
||
|
||
def _build_redis_url(req: InitDbRequest) -> str:
|
||
url = "redis://"
|
||
if req.redis_pass:
|
||
url += f"{quote_plus(req.redis_pass)}@"
|
||
url += f"{req.redis_host}:{req.redis_port}/{req.redis_db}"
|
||
return url
|
||
|
||
|
||
def _escape_env_value(value: str) -> str:
|
||
"""Escape a value for safe inclusion in a .env file.
|
||
|
||
.env files are essentially shell source files — unquoted values break
|
||
on spaces, ``#``, ``$``, newlines, backslashes, etc. The safest
|
||
approach is to double-quote the value and escape any internal
|
||
double-quotes and backslashes (Docker/python-dotenv convention).
|
||
"""
|
||
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||
return f'"{escaped}"'
|
||
|
||
|
||
def _write_env(req: InitDbRequest, secret_key: str, api_key: str, encryption_key: str,
|
||
pool_size: int, max_overflow: int, redis_url: str,
|
||
site_url: str) -> None:
|
||
install_dir = str(ROOT_DIR)
|
||
db_url = (
|
||
f"mysql+aiomysql://{req.db_user}:{quote_plus(req.db_pass)}"
|
||
f"@{req.db_host}:{req.db_port}/{req.db_name}"
|
||
)
|
||
|
||
lines = [
|
||
"# Nexus .env — Auto-generated by installer",
|
||
f"# {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC",
|
||
"",
|
||
f"NEXUS_SYSTEM_NAME={_escape_env_value('Nexus')}",
|
||
f"NEXUS_SYSTEM_TITLE={_escape_env_value('Nexus — 服务器运维管理平台')}",
|
||
"",
|
||
"NEXUS_HOST=0.0.0.0",
|
||
f"NEXUS_PORT={req.api_port}",
|
||
"",
|
||
"# Database (MySQL 8.4 + aiomysql async driver)",
|
||
f"NEXUS_DATABASE_URL={_escape_env_value(db_url)}",
|
||
f"NEXUS_DB_POOL_SIZE={pool_size}",
|
||
f"NEXUS_DB_MAX_OVERFLOW={max_overflow}",
|
||
"",
|
||
"# Security — PRODUCTION KEYS (immutable after install)",
|
||
f"NEXUS_SECRET_KEY={_escape_env_value(secret_key)}",
|
||
f"NEXUS_API_KEY={_escape_env_value(api_key)}",
|
||
f"NEXUS_ENCRYPTION_KEY={_escape_env_value(encryption_key)}",
|
||
"",
|
||
"# Redis",
|
||
f"NEXUS_REDIS_URL={_escape_env_value(redis_url)}",
|
||
"",
|
||
"# Deployment",
|
||
f"NEXUS_DEPLOY_PATH={_escape_env_value(install_dir)}",
|
||
f"NEXUS_CORS_ORIGINS={_escape_env_value(f'{site_url},http://localhost:{req.api_port}')}",
|
||
"",
|
||
"# SSH",
|
||
"NEXUS_SSH_STRICT_HOST_CHECKING=true",
|
||
"",
|
||
"# Health Check",
|
||
"NEXUS_HEALTH_CHECK_INTERVAL=60",
|
||
f"NEXUS_API_BASE_URL={_escape_env_value(site_url)}",
|
||
"",
|
||
"# Alert Thresholds",
|
||
"NEXUS_CPU_ALERT_THRESHOLD=80",
|
||
"NEXUS_MEM_ALERT_THRESHOLD=80",
|
||
"NEXUS_DISK_ALERT_THRESHOLD=80",
|
||
"",
|
||
"# Telegram Alerts (optional — configure in Settings UI)",
|
||
"NEXUS_TELEGRAM_BOT_TOKEN=",
|
||
"NEXUS_TELEGRAM_CHAT_ID=",
|
||
"",
|
||
]
|
||
|
||
write_utf8_lf(ENV_FILE, "\n".join(lines) + "\n")
|
||
logger.info(f".env written to {ENV_FILE}")
|
||
|
||
|
||
def _write_config_json(req: InitDbRequest, api_key: str, site_url: str) -> None:
|
||
"""Write shared configuration as JSON (replaces legacy config.php)."""
|
||
import json
|
||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
install_dir = str(ROOT_DIR)
|
||
|
||
config = {
|
||
"_generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||
"_generator": "Nexus 6.0 Installer",
|
||
"api_base_url": site_url,
|
||
"api_key": api_key,
|
||
"db_host": req.db_host,
|
||
"db_port": req.db_port,
|
||
"db_name": req.db_name,
|
||
"db_user": req.db_user,
|
||
"db_pass": req.db_pass,
|
||
"app_name": "Nexus",
|
||
"app_version": "6.0.0",
|
||
"session_lifetime": 28800,
|
||
"deploy_root": install_dir,
|
||
"timezone": req.timezone,
|
||
}
|
||
|
||
write_utf8_lf(CONFIG_JSON, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
|
||
logger.info(f"config.json written to {CONFIG_JSON}")
|
||
|
||
|
||
def _detect_bt_panel() -> bool:
|
||
"""Detect if BT Panel (宝塔面板) is installed on this system."""
|
||
return Path("/www/server/panel/class/common.py").exists()
|
||
|
||
|
||
def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
|
||
"""Best-effort: configure Supervisor, crontab, health_monitor.sh"""
|
||
results: list[str] = []
|
||
bt_panel = _detect_bt_panel()
|
||
|
||
# 1. Log directory
|
||
if bt_panel:
|
||
log_dir = Path("/www/wwwlogs")
|
||
else:
|
||
log_dir = Path("/var/log/nexus")
|
||
try:
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
results.append(f"✓ 日志目录已创建 {log_dir}")
|
||
except OSError:
|
||
results.append("✗ 日志目录创建失败(需 root 权限)")
|
||
|
||
# 2. Supervisor config
|
||
if bt_panel:
|
||
conf_path = Path("/www/server/panel/plugin/supervisor/profile/nexus.ini")
|
||
else:
|
||
conf_path = Path("/etc/supervisor/conf.d/nexus.conf")
|
||
|
||
supervisor_conf = (
|
||
f"[program:nexus]\n"
|
||
f"command={install_dir}/venv/bin/uvicorn server.main:app --host 127.0.0.1 --port {api_port}\n"
|
||
f"directory={install_dir}\n"
|
||
f"user=root\n"
|
||
f"autostart=true\n"
|
||
f"autorestart=true\n"
|
||
f"startretries=10\n"
|
||
f"startsecs=3\n"
|
||
f"stopwaitsecs=10\n"
|
||
f"stopsignal=INT\n"
|
||
f"environment=PATH=\"{install_dir}/venv/bin:/usr/local/bin:%(ENV_PATH)s\",HOME=\"/root\"\n"
|
||
f"stderr_logfile={log_dir}/nexus_error.log\n"
|
||
f"stdout_logfile={log_dir}/nexus_access.log\n"
|
||
f"stderr_logfile_maxbytes=50MB\n"
|
||
f"stdout_logfile_maxbytes=50MB\n"
|
||
f"stderr_logfile_backups=5\n"
|
||
f"stdout_logfile_backups=5\n"
|
||
)
|
||
try:
|
||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||
write_utf8_lf(conf_path, supervisor_conf)
|
||
mode_label = "宝塔" if bt_panel else "标准"
|
||
results.append(f"✓ Supervisor 配置已写入 {conf_path}({mode_label}模式)")
|
||
except OSError:
|
||
results.append("✗ Supervisor 配置写入失败(需 root 权限)")
|
||
|
||
# 3. Update health_monitor.sh INSTALL_DIR
|
||
health_sh = Path(install_dir) / "deploy" / "health_monitor.sh"
|
||
if health_sh.exists():
|
||
try:
|
||
content = read_utf8_text(health_sh)
|
||
import re
|
||
content = re.sub(
|
||
r'INSTALL_DIR="[^"]*"',
|
||
f'INSTALL_DIR="{install_dir}"',
|
||
content,
|
||
)
|
||
write_utf8_lf(health_sh, content)
|
||
health_sh.chmod(0o755)
|
||
results.append("✓ health_monitor.sh INSTALL_DIR 已更新")
|
||
except OSError:
|
||
results.append("✗ health_monitor.sh 更新失败")
|
||
else:
|
||
results.append("⚠ health_monitor.sh 未找到,跳过")
|
||
|
||
# 4. Crontab
|
||
cron_entry = f"* * * * * {install_dir}/deploy/health_monitor.sh"
|
||
try:
|
||
current = subprocess.run(
|
||
["crontab", "-l"], capture_output=True, text=True, timeout=5
|
||
).stdout or ""
|
||
if "health_monitor.sh" not in current:
|
||
new_cron = current.rstrip() + "\n" + cron_entry + "\n"
|
||
proc = subprocess.run(
|
||
["crontab", "-"], input=new_cron, capture_output=True, text=True, timeout=5
|
||
)
|
||
if proc.returncode == 0:
|
||
results.append("✓ Crontab 已配置(每分钟健康检查)")
|
||
else:
|
||
results.append("✗ Crontab 配置失败 — 请手动添加")
|
||
else:
|
||
results.append("✓ Crontab 已存在 health_monitor 条目")
|
||
except Exception:
|
||
results.append("⚠ Crontab 配置跳过(权限不足或 cron 不可用)")
|
||
|
||
# 5. Reload Supervisor
|
||
try:
|
||
subprocess.run(["supervisorctl", "reread"], capture_output=True, timeout=5)
|
||
subprocess.run(["supervisorctl", "update"], capture_output=True, timeout=5)
|
||
results.append("✓ Supervisor 已重载配置")
|
||
except Exception:
|
||
results.append("⚠ Supervisor 未运行 — 请手动执行 supervisorctl reread && supervisorctl update")
|
||
|
||
return results
|
||
|
||
|
||
# ── Endpoints ──
|
||
|
||
@router.get("/status")
|
||
async def install_status():
|
||
"""Check whether Nexus is already installed."""
|
||
return {
|
||
"installed": _is_installed(),
|
||
"locked": _is_locked(),
|
||
}
|
||
|
||
|
||
def _reject_if_installed():
|
||
"""Block install wizard after .env exists (except GET /status)."""
|
||
if _is_installed():
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="系统已安装,安装向导已关闭。仅可查询 /api/install/status",
|
||
)
|
||
|
||
|
||
# Backward-compatible alias
|
||
_reject_post_install_wizard = _reject_if_installed
|
||
|
||
|
||
@router.get("/env-check")
|
||
async def env_check():
|
||
"""Detect environment readiness — Python, MySQL client, Redis, write permissions."""
|
||
_reject_post_install_wizard()
|
||
import sys
|
||
|
||
checks = []
|
||
|
||
# Python version
|
||
checks.append({
|
||
"name": "Python 版本",
|
||
"required": "3.12+",
|
||
"current": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||
"pass": sys.version_info >= (3, 12),
|
||
})
|
||
|
||
# MySQL client (aiomysql)
|
||
try:
|
||
import aiomysql
|
||
checks.append({
|
||
"name": "aiomysql 驱动",
|
||
"required": "已安装",
|
||
"current": f"✓ {aiomysql.__version__}",
|
||
"pass": True,
|
||
})
|
||
except ImportError:
|
||
checks.append({
|
||
"name": "aiomysql 驱动",
|
||
"required": "已安装",
|
||
"current": "✗ 未安装",
|
||
"pass": False,
|
||
})
|
||
|
||
# Redis
|
||
redis_ok = False
|
||
redis_msg = "未安装"
|
||
try:
|
||
import redis as rlib
|
||
try:
|
||
r = rlib.Redis(host="127.0.0.1", port=6379, socket_timeout=0.5)
|
||
r.ping()
|
||
redis_ok = True
|
||
redis_msg = "✓ 已连接 127.0.0.1:6379"
|
||
except Exception as e:
|
||
redis_msg = f"连接失败: {e}"
|
||
except ImportError:
|
||
redis_msg = "redis 库未安装"
|
||
checks.append({
|
||
"name": "Redis",
|
||
"required": "已连接",
|
||
"current": redis_msg,
|
||
"pass": redis_ok,
|
||
})
|
||
|
||
# MySQL database — user configures in step 3
|
||
checks.append({
|
||
"name": "MySQL 数据库",
|
||
"required": "步骤3配置",
|
||
"current": "— 请在步骤3填写数据库连接信息",
|
||
"pass": True,
|
||
})
|
||
|
||
# Write permissions
|
||
data_writable = CONFIG_DIR.exists() and os.access(CONFIG_DIR, os.W_OK) or \
|
||
os.access(ROOT_DIR / "web", os.W_OK)
|
||
root_writable = os.access(ROOT_DIR, os.W_OK)
|
||
checks.append({
|
||
"name": "web/data 写入权限",
|
||
"required": "可写",
|
||
"current": "✓ 可写" if data_writable else "✗ 不可写",
|
||
"pass": data_writable,
|
||
})
|
||
checks.append({
|
||
"name": "根目录写入权限 (.env)",
|
||
"required": "可写",
|
||
"current": "✓ 可写" if root_writable else "✗ 不可写",
|
||
"pass": root_writable,
|
||
})
|
||
|
||
all_pass = all(c["pass"] for c in checks)
|
||
return {"checks": checks, "all_pass": all_pass}
|
||
|
||
|
||
@router.post("/init-db")
|
||
async def init_db(req: InitDbRequest):
|
||
"""Step 3: Connect to MySQL, create tables, write configs."""
|
||
_reject_if_installed()
|
||
|
||
db_url = _make_db_url(req)
|
||
redis_url = _build_redis_url(req)
|
||
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
|
||
|
||
try:
|
||
patch_aiomysql_do_ping()
|
||
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
|
||
except Exception as e:
|
||
raise HTTPException(400, f"数据库引擎创建失败: {e}") from e
|
||
|
||
try:
|
||
async with engine.begin() as conn:
|
||
# Create tables using SQLAlchemy metadata
|
||
from server.domain.models import Base
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
|
||
# Create additional indexes
|
||
indexes = [
|
||
"ALTER TABLE servers ADD INDEX idx_servers_is_online (is_online)",
|
||
"ALTER TABLE servers ADD INDEX idx_servers_category (category)",
|
||
"ALTER TABLE servers ADD INDEX idx_servers_platform_id (platform_id)",
|
||
"ALTER TABLE servers ADD INDEX idx_servers_node_id (node_id)",
|
||
"ALTER TABLE sync_logs ADD INDEX idx_sync_logs_srv_start (server_id, started_at)",
|
||
"ALTER TABLE login_attempts ADD INDEX idx_login_attempts_username_ip (username, ip_address)",
|
||
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_server_id (server_id)",
|
||
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_admin_id (admin_id)",
|
||
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_srv_time (server_id, created_at)",
|
||
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_session_id (session_id)",
|
||
]
|
||
for idx_sql in indexes:
|
||
try:
|
||
await conn.execute(text(idx_sql))
|
||
except Exception: # noqa: S110 — DDL idempotent, may already exist
|
||
pass # Index may already exist
|
||
|
||
# Schema migrations for existing databases (safe — no-op if column exists)
|
||
migrations = [
|
||
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
|
||
]
|
||
for mig_sql in migrations:
|
||
try:
|
||
await conn.execute(text(mig_sql))
|
||
except Exception: # noqa: S110 — DDL idempotent, may already exist
|
||
pass # Column may already exist
|
||
|
||
# Calculate pool_size from max_connections
|
||
try:
|
||
result = await conn.execute(text("SHOW VARIABLES LIKE 'max_connections'"))
|
||
row = result.fetchone()
|
||
max_conn = max(100, int(row[1]) if row else 100)
|
||
pool_size = max(20, int(max_conn * 0.4))
|
||
max_overflow = max(20, int(max_conn * 0.3))
|
||
except Exception:
|
||
pool_size, max_overflow = 160, 120
|
||
|
||
# Generate keys
|
||
secret_key = secrets.token_hex(32)
|
||
api_key = secrets.token_hex(16)
|
||
# Generate Fernet-compatible encryption key (32 url-safe base64 bytes)
|
||
import base64
|
||
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
|
||
|
||
# Insert settings
|
||
settings_kv = {
|
||
"api_key": api_key,
|
||
"secret_key": secret_key,
|
||
"api_base_url": site_url,
|
||
"system_name": "Nexus",
|
||
"system_title": "Nexus — 服务器运维管理平台",
|
||
"db_pool_size": str(pool_size),
|
||
"db_max_overflow": str(max_overflow),
|
||
"redis_url": redis_url,
|
||
"heartbeat_timeout": "60",
|
||
"cpu_alert_threshold": "80",
|
||
"mem_alert_threshold": "80",
|
||
"disk_alert_threshold": "80",
|
||
"telegram_bot_token": "",
|
||
"telegram_chat_id": "",
|
||
}
|
||
for k, v in settings_kv.items():
|
||
await conn.execute(
|
||
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
|
||
"ON DUPLICATE KEY UPDATE `value` = :v2"),
|
||
{"k": k, "v": v, "v2": v},
|
||
)
|
||
|
||
except Exception as e:
|
||
await engine.dispose()
|
||
raise HTTPException(400, f"数据库初始化失败: {e}") from e
|
||
|
||
await engine.dispose()
|
||
|
||
# Write .env
|
||
_write_env(req, secret_key, api_key, encryption_key, pool_size, max_overflow, redis_url, site_url)
|
||
|
||
# Write config.json
|
||
_write_config_json(req, api_key, site_url)
|
||
|
||
# Configure process guardian (best-effort)
|
||
install_dir = str(ROOT_DIR)
|
||
guardian_results = _configure_guardian(install_dir, req.api_port)
|
||
|
||
# Save install state for step 4
|
||
import json
|
||
state = {
|
||
"db_host": req.db_host,
|
||
"db_port": req.db_port,
|
||
"db_name": req.db_name,
|
||
"db_user": req.db_user,
|
||
# P2-19: Don't store db_pass in state file — only needed for _make_db_url()
|
||
# which is called with the full CreateAdminRequest in step 4
|
||
"pool_size": pool_size,
|
||
"max_overflow": max_overflow,
|
||
"install_dir": install_dir,
|
||
"site_url": site_url,
|
||
"api_port": req.api_port,
|
||
"guardian_results": guardian_results,
|
||
"step": 3,
|
||
}
|
||
write_utf8_lf(STATE_FILE, json.dumps(state, ensure_ascii=False) + "\n")
|
||
|
||
return {
|
||
"success": True,
|
||
"pool_size": pool_size,
|
||
"max_overflow": max_overflow,
|
||
"tables_created": 14,
|
||
"guardian_results": guardian_results,
|
||
"bt_panel": _detect_bt_panel(),
|
||
}
|
||
|
||
|
||
@router.post("/create-admin")
|
||
async def create_admin(req: CreateAdminRequest):
|
||
"""Step 4: Create admin account and set brand name."""
|
||
_reject_if_installed()
|
||
if _is_locked():
|
||
raise HTTPException(400, "系统已锁定")
|
||
|
||
if len(req.admin_password) < 6:
|
||
raise HTTPException(400, "密码长度至少6位")
|
||
|
||
db_url = _make_db_url(req)
|
||
|
||
try:
|
||
patch_aiomysql_do_ping()
|
||
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
|
||
async with engine.begin() as conn:
|
||
# Hash password with bcrypt
|
||
import bcrypt
|
||
password_hash = bcrypt.hashpw(
|
||
req.admin_password.encode("utf-8"),
|
||
bcrypt.gensalt(),
|
||
).decode("utf-8")
|
||
|
||
# Insert admin
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO admins (username, password_hash, email) "
|
||
"VALUES (:u, :h, :e) "
|
||
"ON DUPLICATE KEY UPDATE password_hash = :h2, email = :e2"
|
||
),
|
||
{
|
||
"u": req.admin_username,
|
||
"h": password_hash,
|
||
"e": req.admin_email or None,
|
||
"h2": password_hash,
|
||
"e2": req.admin_email or None,
|
||
},
|
||
)
|
||
|
||
# Update brand in settings
|
||
await conn.execute(
|
||
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
|
||
"ON DUPLICATE KEY UPDATE `value` = :v2"),
|
||
{"k": "system_name", "v": req.system_name, "v2": req.system_name},
|
||
)
|
||
await conn.execute(
|
||
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
|
||
"ON DUPLICATE KEY UPDATE `value` = :v2"),
|
||
{"k": "system_title", "v": req.system_title, "v2": req.system_title},
|
||
)
|
||
|
||
await engine.dispose()
|
||
except Exception as e:
|
||
raise HTTPException(400, f"创建管理员失败: {e}") from e
|
||
|
||
# Update config.json with brand
|
||
if CONFIG_JSON.exists():
|
||
import json
|
||
try:
|
||
config = json.loads(read_utf8_text(CONFIG_JSON))
|
||
config["app_name"] = req.system_name
|
||
write_utf8_lf(CONFIG_JSON, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
|
||
except Exception: # noqa: S110 — best-effort cleanup
|
||
pass
|
||
|
||
# Update .env with brand
|
||
if ENV_FILE.exists():
|
||
import re
|
||
content = read_utf8_text(ENV_FILE)
|
||
content = re.sub(
|
||
r"NEXUS_SYSTEM_NAME=.*",
|
||
f"NEXUS_SYSTEM_NAME={_escape_env_value(req.system_name)}",
|
||
content,
|
||
)
|
||
content = re.sub(
|
||
r"NEXUS_SYSTEM_TITLE=.*",
|
||
f"NEXUS_SYSTEM_TITLE={_escape_env_value(req.system_title)}",
|
||
content,
|
||
)
|
||
write_utf8_lf(ENV_FILE, content)
|
||
|
||
# Update state
|
||
if STATE_FILE.exists():
|
||
import json
|
||
state = json.loads(read_utf8_text(STATE_FILE))
|
||
state["step"] = 4
|
||
state["system_name"] = req.system_name
|
||
write_utf8_lf(STATE_FILE, json.dumps(state, ensure_ascii=False) + "\n")
|
||
|
||
return {"success": True}
|
||
|
||
|
||
@router.post("/lock")
|
||
async def lock_install():
|
||
"""Step 5: Lock the installer — prevent re-installation.
|
||
|
||
Requires that an admin account exists (step 4 completed) before locking.
|
||
This prevents unauthenticated callers from locking the installer
|
||
before the legitimate admin has finished setup.
|
||
"""
|
||
_reject_if_installed()
|
||
|
||
# Verify at least one active admin account exists before allowing lock
|
||
try:
|
||
from server.infrastructure.database.session import AsyncSessionLocal
|
||
from sqlalchemy import text as _text
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
_text("SELECT COUNT(*) FROM admins WHERE is_active = 1")
|
||
)
|
||
count = result.scalar() or 0
|
||
if count == 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="无法锁定:尚无活跃管理员账户。请先完成安装步骤4。",
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning("Failed to verify admin account before locking: %s", e)
|
||
# If DB is temporarily unreachable, conservatively reject lock
|
||
raise HTTPException(status_code=503, detail="数据库暂时不可用,无法验证管理员账户,请稍后重试。")
|
||
|
||
# Create lock marker file
|
||
write_utf8_lf(
|
||
INSTALL_LOCK,
|
||
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n",
|
||
)
|
||
|
||
# Clean up state file
|
||
if STATE_FILE.exists():
|
||
try:
|
||
STATE_FILE.unlink()
|
||
except OSError as e:
|
||
logger.warning("Failed to remove install state file: %s", e)
|
||
|
||
return {"success": True}
|
||
|
||
|
||
@router.get("/state")
|
||
async def get_install_state():
|
||
"""Get current install state (for step navigation)."""
|
||
_reject_post_install_wizard()
|
||
import json
|
||
|
||
if not STATE_FILE.exists():
|
||
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
|
||
|
||
try:
|
||
state = json.loads(read_utf8_text(STATE_FILE))
|
||
state["installed"] = _is_installed()
|
||
state["locked"] = _is_locked()
|
||
return state
|
||
except Exception:
|
||
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
|