"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启,备份恢复.""" from __future__ import annotations import os from functools import wraps from typing import Any, Callable from flask import jsonify, request, send_file, session from lib.env.env_file_lib import apply_env_updates from lib.env.env_ui_manifest import ( build_env_ui_payload, filter_updates_for_ui, coerce_hedge_partial_close_with_manual, validate_env_ui_updates, ) from lib.env.env_schema import parse_env_example_schema from lib.instance.instance_display_prefs_lib import ( display_meta_for_ui, get_display_prefs, normalize_display_prefs, save_display_prefs, tab_allowed, ) from lib.instance.instance_pm2_lib import restart_instance_pm2 from lib.instance.runtime_config_lib import apply_env_reload def _api_login_required(hub_token_write_allowed: bool = False): def decorator(f): @wraps(f) def wrapped(*args, **kwargs): logged_in = bool(session.get("logged_in")) auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in ( "1", "true", "yes", "on", ) hub_hdr = (request.headers.get("X-Hub-Token") or "").strip() bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed: return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403 # crypto_okx 可无 lib.hub;鉴权与中控 request_allowed 对齐 allowed = bool(auth_disabled or logged_in) if not allowed: try: from lib.hub.hub_bridge_auth import hub_embed_sso_ok if hub_embed_sso_ok(request): session["logged_in"] = True session.permanent = True session.modified = True allowed = True except Exception: pass if not allowed and hub_hdr and bridge and hub_hdr == bridge: allowed = True if not allowed: try: from lib.hub.hub_auth import request_allowed as hub_request_allowed allowed = bool(hub_request_allowed(logged_in, auth_disabled)) except ImportError: allowed = False if allowed: return f(*args, **kwargs) return jsonify({"ok": False, "msg": "未登录"}), 401 return wrapped return decorator def register_instance_settings_routes( app, *, get_db: Callable, login_required_fn: Callable, base_dir: str, exchange_key: str, username: str, password: str, db_path: str | None = None, upload_folder: str | None = None, ) -> None: env_path = os.path.join(base_dir, ".env") example_path = os.path.join(base_dir, ".env.example") api_auth = _api_login_required() resolved_db = (db_path or "").strip() or os.path.join(base_dir, "crypto.db") resolved_upload = (upload_folder or "").strip() or os.path.join(base_dir, "static", "images") @app.route("/api/settings/display", methods=["GET", "POST"]) @api_auth def api_settings_display(): if request.method == "GET": prefs = get_display_prefs(get_db) return jsonify( { "ok": True, "display": prefs, "meta": display_meta_for_ui(), } ) body = request.get_json(silent=True) or {} raw = body.get("display") if isinstance(body.get("display"), dict) else body saved = save_display_prefs(get_db, raw) return jsonify({"ok": True, "display": saved}) @app.route("/api/settings/env/meta", methods=["GET"]) @api_auth def api_env_meta(): groups = build_env_ui_payload(exchange_key, example_path, env_path) return jsonify({"ok": True, "groups": groups}) @app.route("/api/settings/env", methods=["GET", "POST"]) @api_auth def api_settings_env(): if request.method == "GET": groups = build_env_ui_payload(exchange_key, example_path, env_path) return jsonify({"ok": True, "groups": groups}) body = request.get_json(silent=True) or {} updates = body.get("values") if isinstance(body.get("values"), dict) else body if not isinstance(updates, dict): return jsonify({"ok": False, "msg": "无效请求体"}), 400 updates = filter_updates_for_ui(exchange_key, updates) clean, errors = validate_env_ui_updates(exchange_key, example_path, updates) if errors: return jsonify({"ok": False, "msg": "; ".join(errors)}), 400 clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path) if not clean: return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) if "OKX_OPTIONS_MARGIN_MODE" in clean: # 币本位专用机:忽略任何写入,强制 coin clean["OKX_OPTIONS_MARGIN_MODE"] = "coin" changed = apply_env_updates(env_path, clean) groups = parse_env_example_schema(example_path) reload_info = apply_env_reload(env_path, get_db, changed, groups) return jsonify( { "ok": True, "changed_keys": changed, "restart_required": reload_info.get("restart_required", False), } ) @app.route("/api/settings/password", methods=["POST"]) @api_auth def api_change_password(): body = request.get_json(silent=True) or {} old_password = str(body.get("old_password") or "") new_username = str(body.get("new_username") or "").strip() new_password = str(body.get("new_password") or "") confirm = str(body.get("confirm_password") or "") if not old_password or old_password != password: return jsonify({"ok": False, "msg": "当前密码错误"}), 400 if len(new_password) < 6: return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400 if new_password != confirm: return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400 updates: dict[str, str] = {"APP_PASSWORD": new_password} if new_username: updates["APP_USERNAME"] = new_username changed = apply_env_updates(env_path, updates) groups = parse_env_example_schema(example_path) apply_env_reload(env_path, get_db, changed, groups) return jsonify({"ok": True, "restart_required": True, "changed_keys": changed}) @app.route("/api/admin/restart", methods=["POST"]) @api_auth def api_admin_restart(): result = restart_instance_pm2(exchange_key, defer=True) code = 200 if result.get("ok") else 500 return jsonify({"ok": bool(result.get("ok")), **result}), code @app.route("/api/admin/health", methods=["GET"]) def api_admin_health(): return jsonify({"ok": True, "status": "up"}) @app.route("/api/settings/backup/list", methods=["GET"]) @api_auth def api_settings_backup_list(): from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups return jsonify( { "ok": True, "items": list_backups(), "backup_dir": backup_dir(), "keep_days": backup_keep_days(), "auto_hour_bj": 3, "includes": [".env", "database", "options_journal"], } ) @app.route("/api/settings/backup/create", methods=["POST"]) @api_auth def api_settings_backup_create(): from lib.instance.backup_lib import create_backup try: result = create_backup( base_dir=base_dir, db_path=resolved_db, upload_folder=resolved_upload, label="manual", ) return jsonify(result) except Exception as exc: return jsonify({"ok": False, "msg": str(exc) or "备份失败"}), 500 @app.route("/api/settings/backup/download/", methods=["GET"]) @api_auth def api_settings_backup_download(name: str): from lib.instance.backup_lib import backup_path path = backup_path(name) if not path or not os.path.isfile(path): return jsonify({"ok": False, "msg": "备份不存在"}), 404 return send_file( path, mimetype="application/gzip", as_attachment=True, download_name=os.path.basename(path), ) @app.route("/api/settings/backup/restore", methods=["POST"]) @api_auth def api_settings_backup_restore(): from lib.instance.backup_lib import restore_backup body = request.get_json(silent=True) or {} name = str(body.get("name") or "").strip() if not name: return jsonify({"ok": False, "msg": "缺少备份文件名"}), 400 result = restore_backup( name, base_dir=base_dir, db_path=resolved_db, upload_folder=resolved_upload, ) code = 200 if result.get("ok") else 400 return jsonify(result), code def tab_allowed_fn(tab: str) -> bool: prefs = get_display_prefs(get_db) return tab_allowed(tab, prefs) app.config["INSTANCE_GET_DB"] = get_db app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn @app.route("/api/embed/tab_allowed/", methods=["GET"]) @api_auth def api_tab_allowed(tab: str): prefs = get_display_prefs(get_db) return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)}) def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]: from lib.instance.instance_settings_lib import settings_page_context prefs = get_display_prefs(get_db) ctx = { "display": prefs, "display_meta": display_meta_for_ui(), **settings_page_context(page, display=prefs, **settings_kwargs), } return ctx