mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-05 03:39:59 +08:00
✨ 首次启动时提供使用web ui方式完全配置 (#1870)
* ✨ 添加全局优先级hook * ✨ 添加基础配置api * ✨ 添加数据库连接测试 * 💬 提示重启 * 🩹 填充过配置时友好提示 * 🐛 首次生成简易配置后自动加载 * ✨ 添加配置后重启接口 * ✨ 添加重启标志文件 * ✨ 添加重启脚本命令 * ✨ 添加重启系统限制 * ✨ 首次配置判断是否为win系统 * 🔥 移除bat * ✨ 添加关于菜单 * ✨ 支持整合包插件安装和添加整合包文档 * 🩹 检测数据库路径 * 🩹 修改数据库路径检测 * 🩹 修改数据库路径检测 * 🩹 修复路径注入 * 🎨 显示添加优先级 * 🐛 修改PriorityLifecycle字典类名称 * ⚡ 修复路径问题 * ⚡ 修复路径检测 * ✨ 新增路径验证功能,确保用户输入的路径安全并在项目根目录内 * ✨ 优化路径验证功能,增加对非法字符和路径长度的检查,确保用户输入的路径更加安全 * 🚨 auto fix by pre-commit hooks * ✨ 优化获取文件列表的代码格式 * 📝 修改README中webui示例图 * ✨ 更新PriorityLifecycle.on_startup装饰器 * ✨ 简化安装依赖的命令构建逻辑 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
pre-commit-ci[bot]
parent
bcfb47d9fd
commit
99f1388e23
@@ -0,0 +1,133 @@
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
import nonebot
|
||||
|
||||
from zhenxun.configs.config import BotConfig, Config
|
||||
|
||||
from ...base_model import Result
|
||||
from .data_source import test_db_connection
|
||||
from .model import Setting
|
||||
|
||||
router = APIRouter(prefix="/configure")
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
port = driver.config.port
|
||||
|
||||
BAT_FILE = Path() / "win启动.bat"
|
||||
|
||||
FILE_NAME = ".configure_restart"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/set_configure",
|
||||
response_model=Result,
|
||||
response_class=JSONResponse,
|
||||
description="设置基础配置",
|
||||
)
|
||||
async def _(setting: Setting) -> Result:
|
||||
global port
|
||||
password = Config.get_config("web-ui", "password")
|
||||
if password or BotConfig.db_url:
|
||||
return Result.fail("配置已存在,请先删除DB_URL内容和前端密码再进行设置。")
|
||||
env_file = Path() / ".env.dev"
|
||||
if not env_file.exists():
|
||||
return Result.fail("配置文件.env.dev不存在。")
|
||||
env_text = env_file.read_text(encoding="utf-8")
|
||||
if setting.db_url:
|
||||
if setting.db_url.startswith("sqlite"):
|
||||
base_dir = Path().resolve()
|
||||
# 清理和验证数据库路径
|
||||
db_path_str = setting.db_url.split(":")[-1].strip()
|
||||
# 移除任何可能的路径遍历尝试
|
||||
db_path_str = re.sub(r"[\\/]\.\.[\\/]", "", db_path_str)
|
||||
# 规范化路径
|
||||
db_path = Path(db_path_str).resolve()
|
||||
parent_path = db_path.parent
|
||||
|
||||
# 验证路径是否在项目根目录内
|
||||
try:
|
||||
if not parent_path.absolute().is_relative_to(base_dir):
|
||||
return Result.fail("数据库路径不在项目根目录内。")
|
||||
except ValueError:
|
||||
return Result.fail("无效的数据库路径。")
|
||||
|
||||
# 创建目录
|
||||
try:
|
||||
parent_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return Result.fail(f"创建数据库目录失败: {e!s}")
|
||||
|
||||
env_text = env_text.replace('DB_URL = ""', f'DB_URL = "{setting.db_url}"')
|
||||
if setting.superusers:
|
||||
superusers = ", ".join([f'"{s}"' for s in setting.superusers])
|
||||
env_text = re.sub(r"SUPERUSERS=\[.*?\]", f"SUPERUSERS=[{superusers}]", env_text)
|
||||
if setting.host:
|
||||
env_text = env_text.replace("HOST = 127.0.0.1", f"HOST = {setting.host}")
|
||||
if setting.port:
|
||||
env_text = env_text.replace("PORT = 8080", f"PORT = {setting.port}")
|
||||
port = setting.port
|
||||
if setting.username:
|
||||
Config.set_config("web-ui", "username", setting.username)
|
||||
Config.set_config("web-ui", "password", setting.password, True)
|
||||
env_file.write_text(env_text, encoding="utf-8")
|
||||
if BAT_FILE.exists():
|
||||
for file in os.listdir(Path()):
|
||||
if file.startswith(FILE_NAME):
|
||||
Path(file).unlink()
|
||||
flag_file = Path() / f"{FILE_NAME}_{int(time.time())}"
|
||||
flag_file.touch()
|
||||
return Result.ok(BAT_FILE.exists(), info="设置成功,请重启真寻以完成配置!")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/test_db",
|
||||
response_model=Result,
|
||||
response_class=JSONResponse,
|
||||
description="设置基础配置",
|
||||
)
|
||||
async def _(db_url: str) -> Result:
|
||||
result = await test_db_connection(db_url)
|
||||
if isinstance(result, str):
|
||||
return Result.fail(result)
|
||||
return Result.ok(info="数据库连接成功!")
|
||||
|
||||
|
||||
async def run_restart_command(bat_path: Path, port: int):
|
||||
"""在后台执行重启命令"""
|
||||
await asyncio.sleep(1) # 确保 FastAPI 已返回响应
|
||||
subprocess.Popen([bat_path, str(port)], shell=True) # noqa: ASYNC220
|
||||
sys.exit(0) # 退出当前进程
|
||||
|
||||
|
||||
@router.post(
|
||||
"/restart",
|
||||
response_model=Result,
|
||||
response_class=JSONResponse,
|
||||
description="重启",
|
||||
)
|
||||
async def _() -> Result:
|
||||
if not BAT_FILE.exists():
|
||||
return Result.fail("自动重启仅支持意见整合包,请尝试手动重启")
|
||||
flag_file = next(
|
||||
(Path() / file for file in os.listdir(Path()) if file.startswith(FILE_NAME)),
|
||||
None,
|
||||
)
|
||||
if not flag_file or not flag_file.exists():
|
||||
return Result.fail("重启标志文件不存在...")
|
||||
set_time = flag_file.name.split("_")[-1]
|
||||
if time.time() - float(set_time) > 10 * 60:
|
||||
return Result.fail("重启标志文件已过期,请重新设置配置。")
|
||||
flag_file.unlink()
|
||||
try:
|
||||
return Result.ok(info="执行重启命令成功")
|
||||
finally:
|
||||
asyncio.create_task(run_restart_command(BAT_FILE, port)) # noqa: RUF006
|
||||
@@ -0,0 +1,18 @@
|
||||
from tortoise import Tortoise
|
||||
|
||||
|
||||
async def test_db_connection(db_url: str) -> bool | str:
|
||||
try:
|
||||
# 初始化 Tortoise ORM
|
||||
await Tortoise.init(
|
||||
db_url=db_url,
|
||||
modules={"models": ["__main__"]}, # 这里不需要实际模型
|
||||
)
|
||||
# 测试连接
|
||||
await Tortoise.get_connection("default").execute_query("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
finally:
|
||||
# 关闭连接
|
||||
await Tortoise.close_connections()
|
||||
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Setting(BaseModel):
|
||||
superusers: list[str]
|
||||
"""超级用户列表"""
|
||||
db_url: str
|
||||
"""数据库地址"""
|
||||
host: str
|
||||
"""主机地址"""
|
||||
port: int
|
||||
"""端口"""
|
||||
username: str
|
||||
"""前端用户名"""
|
||||
password: str
|
||||
"""前端密码"""
|
||||
@@ -5,54 +5,63 @@ from zhenxun.services.log import logger
|
||||
|
||||
from .model import MenuData, MenuItem
|
||||
|
||||
default_menus = [
|
||||
MenuItem(
|
||||
name="仪表盘",
|
||||
module="dashboard",
|
||||
router="/dashboard",
|
||||
icon="dashboard",
|
||||
default=True,
|
||||
),
|
||||
MenuItem(
|
||||
name="真寻控制台",
|
||||
module="command",
|
||||
router="/command",
|
||||
icon="command",
|
||||
),
|
||||
MenuItem(name="插件列表", module="plugin", router="/plugin", icon="plugin"),
|
||||
MenuItem(name="插件商店", module="store", router="/store", icon="store"),
|
||||
MenuItem(name="好友/群组", module="manage", router="/manage", icon="user"),
|
||||
MenuItem(
|
||||
name="数据库管理",
|
||||
module="database",
|
||||
router="/database",
|
||||
icon="database",
|
||||
),
|
||||
MenuItem(name="系统信息", module="system", router="/system", icon="system"),
|
||||
MenuItem(name="关于我们", module="about", router="/about", icon="about"),
|
||||
]
|
||||
|
||||
class MenuManage:
|
||||
|
||||
class MenuManager:
|
||||
def __init__(self) -> None:
|
||||
self.file = DATA_PATH / "web_ui" / "menu.json"
|
||||
self.menu = []
|
||||
if self.file.exists():
|
||||
try:
|
||||
temp_menu = []
|
||||
self.menu = json.load(self.file.open(encoding="utf8"))
|
||||
self_menu_name = [menu["name"] for menu in self.menu]
|
||||
for module in [m.module for m in default_menus]:
|
||||
if module in self_menu_name:
|
||||
temp_menu.append(
|
||||
MenuItem(
|
||||
**next(m for m in self.menu if m["module"] == module)
|
||||
)
|
||||
)
|
||||
else:
|
||||
temp_menu.append(self.__get_menu_model(module))
|
||||
self.menu = temp_menu
|
||||
except Exception as e:
|
||||
logger.warning("菜单文件损坏,已重新生成...", "WebUi", e=e)
|
||||
if not self.menu:
|
||||
self.menu = [
|
||||
MenuItem(
|
||||
name="仪表盘",
|
||||
module="dashboard",
|
||||
router="/dashboard",
|
||||
icon="dashboard",
|
||||
default=True,
|
||||
),
|
||||
MenuItem(
|
||||
name="真寻控制台",
|
||||
module="command",
|
||||
router="/command",
|
||||
icon="command",
|
||||
),
|
||||
MenuItem(
|
||||
name="插件列表", module="plugin", router="/plugin", icon="plugin"
|
||||
),
|
||||
MenuItem(
|
||||
name="插件商店", module="store", router="/store", icon="store"
|
||||
),
|
||||
MenuItem(
|
||||
name="好友/群组", module="manage", router="/manage", icon="user"
|
||||
),
|
||||
MenuItem(
|
||||
name="数据库管理",
|
||||
module="database",
|
||||
router="/database",
|
||||
icon="database",
|
||||
),
|
||||
MenuItem(
|
||||
name="文件管理", module="system", router="/system", icon="system"
|
||||
),
|
||||
MenuItem(
|
||||
name="关于我们", module="about", router="/about", icon="about"
|
||||
),
|
||||
]
|
||||
self.save()
|
||||
self.menu = default_menus
|
||||
self.save()
|
||||
|
||||
def __get_menu_model(self, module: str):
|
||||
return default_menus[
|
||||
next(i for i, m in enumerate(default_menus) if m.module == module)
|
||||
]
|
||||
|
||||
def get_menus(self):
|
||||
return MenuData(menus=self.menu)
|
||||
@@ -64,4 +73,4 @@ class MenuManage:
|
||||
json.dump(temp, f, ensure_ascii=False, indent=4)
|
||||
|
||||
|
||||
menu_manage = MenuManage()
|
||||
menu_manage = MenuManager()
|
||||
|
||||
@@ -13,6 +13,7 @@ from zhenxun.models.bot_connect_log import BotConnectLog
|
||||
from zhenxun.models.chat_history import ChatHistory
|
||||
from zhenxun.models.statistics import Statistics
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
from ....base_model import BaseResultModel, QueryModel
|
||||
@@ -31,7 +32,7 @@ driver: Driver = nonebot.get_driver()
|
||||
CONNECT_TIME = 0
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
@PriorityLifecycle.on_startup(priority=5)
|
||||
async def _():
|
||||
global CONNECT_TIME
|
||||
CONNECT_TIME = int(time.time())
|
||||
|
||||
@@ -8,6 +8,7 @@ from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
||||
|
||||
from ....base_model import BaseResultModel, QueryModel, Result
|
||||
from ....utils import authentication
|
||||
@@ -21,7 +22,7 @@ router = APIRouter(prefix="/database")
|
||||
driver: Driver = nonebot.get_driver()
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
@PriorityLifecycle.on_startup(priority=5)
|
||||
async def _():
|
||||
for plugin in nonebot.get_loaded_plugins():
|
||||
module = plugin.name
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
|
||||
from ....base_model import Result, SystemFolderSize
|
||||
from ....utils import authentication, get_system_disk
|
||||
from ....utils import authentication, get_system_disk, validate_path
|
||||
from .model import AddFile, DeleteFile, DirFile, RenameFile, SaveFile
|
||||
|
||||
router = APIRouter(prefix="/system")
|
||||
@@ -25,22 +25,29 @@ IMAGE_TYPE = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"]
|
||||
description="获取文件列表",
|
||||
)
|
||||
async def _(path: str | None = None) -> Result[list[DirFile]]:
|
||||
base_path = Path(path) if path else Path()
|
||||
data_list = []
|
||||
for file in os.listdir(base_path):
|
||||
file_path = base_path / file
|
||||
is_image = any(file.endswith(f".{t}") for t in IMAGE_TYPE)
|
||||
data_list.append(
|
||||
DirFile(
|
||||
is_file=not file_path.is_dir(),
|
||||
is_image=is_image,
|
||||
name=file,
|
||||
parent=path,
|
||||
size=None if file_path.is_dir() else file_path.stat().st_size,
|
||||
mtime=file_path.stat().st_mtime,
|
||||
try:
|
||||
base_path, error = validate_path(path)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not base_path:
|
||||
return Result.fail("无效的路径")
|
||||
data_list = []
|
||||
for file in os.listdir(base_path):
|
||||
file_path = base_path / file
|
||||
is_image = any(file.endswith(f".{t}") for t in IMAGE_TYPE)
|
||||
data_list.append(
|
||||
DirFile(
|
||||
is_file=not file_path.is_dir(),
|
||||
is_image=is_image,
|
||||
name=file,
|
||||
parent=path,
|
||||
size=None if file_path.is_dir() else file_path.stat().st_size,
|
||||
mtime=file_path.stat().st_mtime,
|
||||
)
|
||||
)
|
||||
)
|
||||
return Result.ok(data_list)
|
||||
return Result.ok(data_list)
|
||||
except Exception as e:
|
||||
return Result.fail(f"获取文件列表失败: {e!s}")
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -62,8 +69,12 @@ async def _(full_path: str | None = None) -> Result[list[SystemFolderSize]]:
|
||||
description="删除文件",
|
||||
)
|
||||
async def _(param: DeleteFile) -> Result:
|
||||
path = Path(param.full_path)
|
||||
if not path or not path.exists():
|
||||
path, error = validate_path(param.full_path)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not path:
|
||||
return Result.fail("无效的路径")
|
||||
if not path.exists():
|
||||
return Result.warning_("文件不存在...")
|
||||
try:
|
||||
path.unlink()
|
||||
@@ -80,8 +91,12 @@ async def _(param: DeleteFile) -> Result:
|
||||
description="删除文件夹",
|
||||
)
|
||||
async def _(param: DeleteFile) -> Result:
|
||||
path = Path(param.full_path)
|
||||
if not path or not path.exists() or path.is_file():
|
||||
path, error = validate_path(param.full_path)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not path:
|
||||
return Result.fail("无效的路径")
|
||||
if not path.exists() or path.is_file():
|
||||
return Result.warning_("文件夹不存在...")
|
||||
try:
|
||||
shutil.rmtree(path.absolute())
|
||||
@@ -98,10 +113,14 @@ async def _(param: DeleteFile) -> Result:
|
||||
description="重命名文件",
|
||||
)
|
||||
async def _(param: RenameFile) -> Result:
|
||||
path = (
|
||||
(Path(param.parent) / param.old_name) if param.parent else Path(param.old_name)
|
||||
)
|
||||
if not path or not path.exists():
|
||||
parent_path, error = validate_path(param.parent)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not parent_path:
|
||||
return Result.fail("无效的路径")
|
||||
|
||||
path = (parent_path / param.old_name) if param.parent else Path(param.old_name)
|
||||
if not path.exists():
|
||||
return Result.warning_("文件不存在...")
|
||||
try:
|
||||
path.rename(path.parent / param.name)
|
||||
@@ -118,10 +137,14 @@ async def _(param: RenameFile) -> Result:
|
||||
description="重命名文件夹",
|
||||
)
|
||||
async def _(param: RenameFile) -> Result:
|
||||
path = (
|
||||
(Path(param.parent) / param.old_name) if param.parent else Path(param.old_name)
|
||||
)
|
||||
if not path or not path.exists() or path.is_file():
|
||||
parent_path, error = validate_path(param.parent)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not parent_path:
|
||||
return Result.fail("无效的路径")
|
||||
|
||||
path = (parent_path / param.old_name) if param.parent else Path(param.old_name)
|
||||
if not path.exists() or path.is_file():
|
||||
return Result.warning_("文件夹不存在...")
|
||||
try:
|
||||
new_path = path.parent / param.name
|
||||
@@ -139,7 +162,13 @@ async def _(param: RenameFile) -> Result:
|
||||
description="新建文件",
|
||||
)
|
||||
async def _(param: AddFile) -> Result:
|
||||
path = (Path(param.parent) / param.name) if param.parent else Path(param.name)
|
||||
parent_path, error = validate_path(param.parent)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not parent_path:
|
||||
return Result.fail("无效的路径")
|
||||
|
||||
path = (parent_path / param.name) if param.parent else Path(param.name)
|
||||
if path.exists():
|
||||
return Result.warning_("文件已存在...")
|
||||
try:
|
||||
@@ -157,7 +186,13 @@ async def _(param: AddFile) -> Result:
|
||||
description="新建文件夹",
|
||||
)
|
||||
async def _(param: AddFile) -> Result:
|
||||
path = (Path(param.parent) / param.name) if param.parent else Path(param.name)
|
||||
parent_path, error = validate_path(param.parent)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not parent_path:
|
||||
return Result.fail("无效的路径")
|
||||
|
||||
path = (parent_path / param.name) if param.parent else Path(param.name)
|
||||
if path.exists():
|
||||
return Result.warning_("文件夹已存在...")
|
||||
try:
|
||||
@@ -175,7 +210,11 @@ async def _(param: AddFile) -> Result:
|
||||
description="读取文件",
|
||||
)
|
||||
async def _(full_path: str) -> Result:
|
||||
path = Path(full_path)
|
||||
path, error = validate_path(full_path)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not path:
|
||||
return Result.fail("无效的路径")
|
||||
if not path.exists():
|
||||
return Result.warning_("文件不存在...")
|
||||
try:
|
||||
@@ -193,9 +232,13 @@ async def _(full_path: str) -> Result:
|
||||
description="读取文件",
|
||||
)
|
||||
async def _(param: SaveFile) -> Result[str]:
|
||||
path = Path(param.full_path)
|
||||
path, error = validate_path(param.full_path)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not path:
|
||||
return Result.fail("无效的路径")
|
||||
try:
|
||||
async with aiofiles.open(path, "w", encoding="utf-8") as f:
|
||||
async with aiofiles.open(str(path), "w", encoding="utf-8") as f:
|
||||
await f.write(param.content)
|
||||
return Result.ok("更新成功!")
|
||||
except Exception as e:
|
||||
@@ -210,7 +253,11 @@ async def _(param: SaveFile) -> Result[str]:
|
||||
description="读取图片base64",
|
||||
)
|
||||
async def _(full_path: str) -> Result[str]:
|
||||
path = Path(full_path)
|
||||
path, error = validate_path(full_path)
|
||||
if error:
|
||||
return Result.fail(error)
|
||||
if not path:
|
||||
return Result.fail("无效的路径")
|
||||
if not path.exists():
|
||||
return Result.warning_("文件不存在...")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user