From a930648c95f2bf5d0f09254184154cb2a42fff06 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Apr 2026 11:18:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20HTTPS/SSL=20=E5=BC=80=E5=85=B3=20?= =?UTF-8?q?=E2=80=94=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E8=B7=AF=E5=BE=84=EF=BC=8C=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/web_api.py | 130 ++++++++++++++++++++++++++++++++----- ui/templates/settings.html | 95 +++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 16 deletions(-) diff --git a/core/web_api.py b/core/web_api.py index 4d14cb8..f29ecea 100644 --- a/core/web_api.py +++ b/core/web_api.py @@ -82,31 +82,69 @@ _last_cleanup = time.time() # Web 服务配置(仅 SECRET_KEY 保留在 json 文件,用户信息在数据库) -def load_secret_key(): +def _find_config_path(): + """查找已有的 web_config.json,或返回默认路径""" import json - defaults = {"SECRET_KEY": "trulymem-secret-key-2026"} search_paths = [ os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json'), os.path.join(os.getcwd(), 'web_config.json'), os.path.join(os.path.expanduser("~"), ".trulymem", 'web_config.json'), ] - for config_path in search_paths: - if os.path.exists(config_path): - try: - with open(config_path, 'r', encoding='utf-8') as f: - file_config = json.load(f) - if "SECRET_KEY" in file_config: - defaults["SECRET_KEY"] = file_config["SECRET_KEY"] - break - except Exception: - continue + for p in search_paths: + if os.path.exists(p): + return p + return search_paths[0] + + +def load_web_config(): + """加载完整 web_config.json""" + import json + defaults = { + "SECRET_KEY": "trulymem-secret-key-2026", + "ssl_enabled": False, + "ssl_cert_path": "", + "ssl_key_path": "", + } + config_path = _find_config_path() + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + file_config = json.load(f) + for k in defaults: + if k in file_config: + defaults[k] = file_config[k] + except Exception: + pass return defaults -WEB_CONFIG = load_secret_key() + +def save_web_config(updates: dict) -> bool: + """更新并保存 web_config.json""" + import json + config_path = _find_config_path() + # 读取已有配置 + current = {} + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + current = json.load(f) + except Exception: + pass + current.update(updates) + try: + os.makedirs(os.path.dirname(config_path), exist_ok=True) + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(current, f, ensure_ascii=False, indent=2) + return True + except Exception: + return False + + +WEB_CONFIG = load_web_config() _ui_dir = os.path.join(os.path.dirname(__file__), '..', 'ui') app = Flask(__name__, static_folder=os.path.join(_ui_dir, 'static'), static_url_path='', template_folder=os.path.join(_ui_dir, 'templates')) -app.secret_key = WEB_CONFIG["SECRET_KEY"] +app.secret_key = WEB_CONFIG.get("SECRET_KEY", "trulymem-secret-key-2026") app.permanent_session_lifetime = timedelta(days=7) CORS(app, supports_credentials=True) # 启用跨域支持,支持 session cookies @@ -559,6 +597,49 @@ def web_settings_config(): return jsonify({"success": False, "error": "没有需要更新的配置"}), 400 +@app.route('/api/ssl/config', methods=['GET', 'POST']) +@api_login_required +def web_ssl_config(): + """获取/更新 SSL 配置(服务器级别,写入 web_config.json,重启后生效)""" + if request.method == 'GET': + cfg = load_web_config() + return jsonify({ + "success": True, + "ssl_enabled": cfg.get('ssl_enabled', False), + "ssl_cert_path": cfg.get('ssl_cert_path', ''), + "ssl_key_path": cfg.get('ssl_key_path', ''), + }) + + data = request.get_json() or {} + updates = {} + + # 提取有用的字段 + for key in ('ssl_enabled', 'ssl_cert_path', 'ssl_key_path'): + if key in data: + updates[key] = data[key] + + if not updates: + return jsonify({"success": False, "error": "没有需要更新的字段"}), 400 + + # 如果启用 SSL,验证证书路径 + if updates.get('ssl_enabled'): + cert_path = updates.get('ssl_cert_path') or load_web_config().get('ssl_cert_path', '') + key_path = updates.get('ssl_key_path') or load_web_config().get('ssl_key_path', '') + if not os.path.exists(cert_path): + return jsonify({"success": False, "error": f"证书文件不存在: {cert_path}"}), 400 + if not os.path.exists(key_path): + return jsonify({"success": False, "error": f"密钥文件不存在: {key_path}"}), 400 + + ok = save_web_config(updates) + if ok: + return jsonify({ + "success": True, + "message": "SSL 配置已保存,重启服务后生效", + **updates + }) + return jsonify({"success": False, "error": "写入配置文件失败"}), 500 + + @app.errorhandler(404) def not_found(e): """404 处理""" @@ -829,8 +910,25 @@ def run_web_server(port: int = 4096, host: str = '0.0.0.0') -> None: global _http_server try: from werkzeug.serving import make_server - _http_server = make_server(host, port, app, threaded=True) - print(f"Web API 服务启动在 http://{host}:{port}") + import ssl + + # 尝试加载 SSL 配置 + cfg = load_web_config() + ssl_enabled = cfg.get('ssl_enabled', False) + ssl_context = None + if ssl_enabled: + cert_path = cfg.get('ssl_cert_path', '') + key_path = cfg.get('ssl_key_path', '') + if cert_path and os.path.exists(cert_path) and key_path and os.path.exists(key_path): + ssl_context = (cert_path, key_path) + print(f"🔒 HTTPS 已启用:cert={cert_path}") + else: + print(f"⚠️ SSL 已启用但证书路径无效:cert={cert_path}, key={key_path}") + print(" 回退到 HTTP") + + _http_server = make_server(host, port, app, threaded=True, ssl_context=ssl_context) + proto = "https" if ssl_context else "http" + print(f"Web API 服务启动在 {proto}://{host}:{port}") _http_server.serve_forever() except Exception as e: print(f"Web 服务启动失败: {e}") diff --git a/ui/templates/settings.html b/ui/templates/settings.html index e2e27f4..c7fa014 100644 --- a/ui/templates/settings.html +++ b/ui/templates/settings.html @@ -263,6 +263,34 @@ + +
HTTPS / SSL
+
+
+
启用 HTTPS
+
开启后使用 HTTPS 协议,需提供有效的证书和密钥文件
+
+ +
+
+
+ + +
+
+ + +
+ +
+
+ 修改后需重启服务生效:systemctl restart trulymem-web +
+
+
TUI 终端服务
@@ -370,6 +398,21 @@ } } + // 加载 SSL 配置 + try { + const sslResp = await fetch('/api/ssl/config'); + const sslData = await sslResp.json(); + if (sslData.success) { + document.getElementById('enableSslToggle').checked = sslData.ssl_enabled === true; + document.getElementById('ssl_cert_path').value = sslData.ssl_cert_path || ''; + document.getElementById('ssl_key_path').value = sslData.ssl_key_path || ''; + toggleSslFields(sslData.ssl_enabled === true); + } + } catch (e) { + // SSL 端点可能不支持 + } + } + // 加载用户列表 async function loadUsers() { try { @@ -616,6 +659,58 @@ loadApiConfig(); }; + // SSL 开关 — 显示/隐藏证书路径 + const sslToggle = document.getElementById('enableSslToggle'); + const sslCertFields = document.getElementById('sslCertFields'); + function toggleSslFields(enabled) { + sslCertFields.style.display = enabled ? '' : 'none'; + } + + sslToggle.addEventListener('change', function() { + toggleSslFields(this.checked); + }); + + // 保存 SSL 配置 + document.getElementById('saveSslConfigBtn').addEventListener('click', async function() { + const enabled = sslToggle.checked; + const certPath = document.getElementById('ssl_cert_path').value.trim(); + const keyPath = document.getElementById('ssl_key_path').value.trim(); + + if (enabled && (!certPath || !keyPath)) { + document.getElementById('sslStatus').innerHTML = ' 启用 HTTPS 必须填写证书和密钥路径'; + return; + } + + const btn = this; + btn.disabled = true; + btn.textContent = '保存中...'; + document.getElementById('sslStatus').innerHTML = ' 保存中...'; + + try { + const resp = await fetch('/api/ssl/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ssl_enabled: enabled, + ssl_cert_path: certPath, + ssl_key_path: keyPath + }) + }); + const data = await resp.json(); + if (data.success) { + document.getElementById('sslStatus').innerHTML = ' ' + (data.message || '配置已保存'); + showSuccess('HTTPS 配置已保存'); + } else { + document.getElementById('sslStatus').innerHTML = ' ' + (data.error || '保存失败'); + } + } catch (e) { + document.getElementById('sslStatus').innerHTML = ' 网络错误'; + } finally { + btn.disabled = false; + btn.textContent = '保 存 HTTPS 配 置'; + } + }); + // TUI 开关 document.getElementById('enableTuiToggle').addEventListener('change', async function() { const enable = this.checked;