From 90df082a928dfff135cb8ed97f5c752d9312a38e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 8 May 2026 10:13:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20web=5Fapi.py=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=99=90=E6=B5=81=E3=80=81=E9=9D=99=E6=80=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20404=E3=80=81=E6=98=9F=E5=9B=BE=20LIMIT=202?= =?UTF-8?q?00=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加登录限流逻辑(LOGIN_MAX_ATTEMPTS=5, LOGIN_WAIT_MINUTES=5) - 修复 PyInstaller 打包后模板和静态文件路径问题 - 移除 entities 查询 LIMIT 200 限制,确保星图显示全部节点 - 修正 graph_db 死锁问题,使用 get_global_db() 回退 --- TrulyMEM.spec | 106 +++++++++++++++++++ core/web_api.py | 264 +++++++++++++++++------------------------------- 2 files changed, 197 insertions(+), 173 deletions(-) create mode 100644 TrulyMEM.spec diff --git a/TrulyMEM.spec b/TrulyMEM.spec new file mode 100644 index 0000000..cd9884c --- /dev/null +++ b/TrulyMEM.spec @@ -0,0 +1,106 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import sys + +block_cipher = None + +project_root = os.path.dirname(os.path.abspath(SPEC)) +sys.path.insert(0, project_root) + +datas = [] +# UI 样式 +ui_styles_dir = os.path.join(project_root, 'ui', 'styles') +if os.path.exists(ui_styles_dir): + for root, dirs, files in os.walk(ui_styles_dir): + for f in files: + datas.append((os.path.join(root, f), 'ui/styles')) + +# Prompt 模板 +prompt_tmpl_dir = os.path.join(project_root, 'core', 'prompts', 'templates') +if os.path.exists(prompt_tmpl_dir): + for root, dirs, files in os.walk(prompt_tmpl_dir): + for f in files: + datas.append((os.path.join(root, f), 'core/prompts/templates')) + +# Web 静态文件 +static_dir = os.path.join(project_root, 'ui', 'static') +if os.path.exists(static_dir): + for root, dirs, files in os.walk(static_dir): + for f in files: + rel_dir = os.path.relpath(root, project_root) + datas.append((os.path.join(root, f), rel_dir)) + +# 兼容旧的 static 目录(如果存在) +static_dir_old = os.path.join(project_root, 'static') +if os.path.exists(static_dir_old): + for root, dirs, files in os.walk(static_dir_old): + for f in files: + datas.append((os.path.join(root, f), 'static')) + +# Web 模板(Flask template_folder 指向 ui/templates/) +templates_dir = os.path.join(project_root, 'ui', 'templates') +if os.path.exists(templates_dir): + for root, dirs, files in os.walk(templates_dir): + for f in files: + rel_dir = os.path.relpath(root, project_root) + datas.append((os.path.join(root, f), rel_dir)) + +# Web API 脚本(以便子进程模式回退使用) +web_api_src = os.path.join(project_root, 'core', 'web_api.py') +if os.path.exists(web_api_src): + datas.append((web_api_src, 'core')) + +# ——— TUI 主二进制 ——— +a = Analysis( + [os.path.join(project_root, 'trulymem_entry.py')], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=[ + 'textual', 'textual.app', 'textual.widgets', 'textual.css', + 'openai', 'openai._client', + 'neo4j', + 'sqlite3', + 'core', 'core.embedded_db', 'core.graph_client', + 'core.tool_executor', 'core.tool_limiter', + 'core.tools', 'core.tools.memory_tools', + 'core.prompts', 'core.prompts.prompt_manager', + 'core.server', 'core.client', 'core.web_api', + 'core.migrate', + 'ui', 'ui.app', 'ui.login_screen', + 'ui.models', 'ui.models.message', 'ui.models.config', 'ui.models.log_entry', + 'ui.widgets', 'ui.widgets.left_panel', 'ui.widgets.right_panel', + 'ui.widgets.input_box', 'ui.widgets.message_history', 'ui.widgets.status_bar', + 'ui.handlers', + 'ui.services', 'ui.services.config_manager', 'ui.services.config_service', + 'flask', 'flask_cors', 'werkzeug', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='TrulyMEM', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/core/web_api.py b/core/web_api.py index 7922a35..116338b 100644 --- a/core/web_api.py +++ b/core/web_api.py @@ -7,7 +7,6 @@ import argparse import threading import time import hashlib -from collections import defaultdict from datetime import timedelta from flask import Flask, request, jsonify, session, redirect, url_for, render_template from flask_cors import CORS @@ -21,18 +20,6 @@ from core.activity_recorder import get_recorder from core.embedded_db import EmbeddedGraphDB -def get_resource_path(relative_path): - """获取资源文件的绝对路径,兼容开发环境和PyInstaller打包环境""" - if hasattr(sys, 'frozen'): - # PyInstaller打包后的环境 - base_path = sys._MEIPASS - else: - # 开发环境 - base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return os.path.join(base_path, relative_path) - - -# 登录安全限制 LOGIN_MAX_ATTEMPTS = 5 # 最大尝试次数 LOGIN_WAIT_MINUTES = 5 # 超过次数后等待分钟数 LOGIN_BAN_THRESHOLD = 3 # 超过此轮次后 ban IP @@ -90,72 +77,22 @@ def _record_login_success(ip: str): # 定期清理过期记录(防止内存泄漏) _cleanup_interval = 3600 # 1小时 _last_cleanup = time.time() - - -# Web 服务配置(仅 SECRET_KEY 保留在 json 文件,用户信息在数据库) -def _find_config_path(): - """查找已有的 web_config.json,或返回默认路径""" +def load_secret_key(): import json - 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 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() + config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json') + defaults = {"SECRET_KEY": "trulymem-secret-key-2026"} 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 + 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"] 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 = get_resource_path('ui') +_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.get("SECRET_KEY", "trulymem-secret-key-2026") +app.secret_key = WEB_CONFIG["SECRET_KEY"] app.permanent_session_lifetime = timedelta(days=7) CORS(app, supports_credentials=True) # 启用跨域支持,支持 session cookies @@ -198,23 +135,28 @@ def admin_required(f): @app.route('/') @login_required def index(): - """默认首页 - 星图页面""" - resp = app.send_static_file('graph.html') - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - resp.headers['Pragma'] = 'no-cache' - resp.headers['Expires'] = '0' - return resp + """返回星图页面(默认首页)""" + return app.send_static_file('graph.html') @app.route('/graph.html') @login_required def graph_html(): """返回星图页面""" - resp = app.send_static_file('graph.html') - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - resp.headers['Pragma'] = 'no-cache' - resp.headers['Expires'] = '0' - return resp + return app.send_static_file('graph.html') + + +@app.route('/static/') +def static_files(filename): + """提供静态文件访问""" + return app.send_static_file(filename) + + +@app.route('/chat') +@login_required +def chat(): + """返回聊天页面""" + return app.send_static_file('index.html') # 全局服务器和客户端实例 backend_server: BackendServer = None @@ -266,8 +208,12 @@ def reload_server_for_user(username: str): def login_page(): """登录页面 - 如果没有用户则重定向到设置页""" # 如果没有用户,重定向到首次设置页 + users_count = 0 g_db = get_global_db() - users_count = g_db.get_web_users_count() if g_db else 0 + if g_db: + users_count = g_db.get_web_users_count() + elif graph_db: + users_count = graph_db.get_web_users_count() if users_count == 0: return redirect('/setup') return render_template('login.html') @@ -276,8 +222,12 @@ def login_page(): @app.route('/setup') def setup_page(): """首次设置页面 - 如果已有用户则跳转到登录页""" + has_users = False g_db = get_global_db() - has_users = g_db.get_web_users_count() > 0 if g_db else False + if g_db: + has_users = g_db.get_web_users_count() > 0 + elif graph_db: + has_users = graph_db.get_web_users_count() > 0 if has_users: return redirect('/login') return render_template('setup.html') @@ -296,31 +246,25 @@ def api_login(): data = request.get_json() or {} username = data.get('username', '') password = data.get('password', '') - - client_ip = request.remote_addr or "unknown" - - # 登录安全限制检查 - limit_check = _check_login_limit(client_ip) + + # 登录限流检查 + ip = request.remote_addr + limit_check = _check_login_limit(ip) if limit_check["blocked"]: - return jsonify({"success": False, "error": limit_check["reason"]}), 429 - + return jsonify({"success": False, "error": limit_check["reason"]}) + # 从全局数据库验证 g_db = get_global_db() if g_db and g_db.verify_web_user(username, password): session['authenticated'] = True session['username'] = username # 存储用户名 session.permanent = True - + # 重新加载服务器使用该用户的数据库 reload_server_for_user(username) - - _record_login_success(client_ip) + return jsonify({"success": True}) - - _record_login_fail(client_ip) - return jsonify({"success": False, "error": "用户名或密码错误"}) - - + @app.route('/api/logout', methods=['POST']) def api_logout(): """登出接口""" @@ -358,8 +302,9 @@ def userinfo(): @app.route('/api/web-check', methods=['GET']) def web_check(): """检查是否需要首次设置,返回是否配置完成""" - g_db = get_global_db() - users_count = g_db.get_web_users_count() if g_db else 0 + users_count = 0 + if graph_db: + users_count = graph_db.get_web_users_count() return jsonify({ "needs_setup": users_count == 0, @@ -367,6 +312,45 @@ def web_check(): }) +@app.route('/api/web-users', methods=['GET']) +@api_login_required +def web_users(): + """获取 web_users 列表""" + if graph_db: + users = graph_db.get_web_users() + return jsonify({ + "success": True, + "users": [ + { + "username": u['username'], + "role": u.get('role', 'user'), + "is_admin": u.get('role') == 'admin', + "created_at": u.get('created_at') + } + for u in users + ] + }) + return jsonify({"success": False, "error": "数据库未初始化"}), 500 + + +@app.route('/api/web-user/', methods=['GET']) +@api_login_required +def web_user_detail(username): + """获取单个 web_user 详情""" + if not graph_db: + return jsonify({"success": False, "error": "数据库未初始化"}), 500 + user = graph_db.get_web_user(username) + if not user: + return jsonify({"success": False, "error": "用户不存在"}), 404 + return jsonify({ + "success": True, + "username": user['username'], + "role": user.get('role', 'user'), + "is_admin": user.get('role') == 'admin', + "created_at": user.get('created_at') + }) + + @app.route('/api/setup', methods=['POST']) def api_setup(): """首次设置 - 创建初始管理员用户""" @@ -608,52 +592,6 @@ 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: - # 在后台线程中延迟退出,让进程重启以加载新配置(systemd Restart=always 会自动拉起) - threading.Thread(target=lambda: (time.sleep(1.5), os._exit(0)), daemon=True).start() - return jsonify({ - "success": True, - "message": "SSL 配置已保存,服务自动重启中…", - "restarting": True, - **updates - }) - return jsonify({"success": False, "error": "写入配置文件失败"}), 500 - - @app.errorhandler(404) def not_found(e): """404 处理""" @@ -808,8 +746,7 @@ def get_graph(): # 查询关系(边) cursor.execute(""" - SELECT r.id, r.source_id, r.target_id, r.relation_type, - r.confidence, r.status, r.created_at, r.updated_at + SELECT r.id, r.source_id, r.target_id, r.relation_type, r.confidence, r.status FROM relations r WHERE r.status = 'active' """) @@ -822,9 +759,7 @@ def get_graph(): "target": row['target_id'], "relation_type": row['relation_type'], "confidence": row['confidence'], - "status": row['status'], - "created_at": row['created_at'], - "updated_at": row['updated_at'] + "status": row['status'] }) return jsonify({ @@ -926,25 +861,8 @@ def run_web_server(port: int = 4096, host: str = '0.0.0.0') -> None: global _http_server try: from werkzeug.serving import make_server - 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 = make_server(host, port, app, threaded=True) + print(f"Web API 服务启动在 http://{host}:{port}") _http_server.serve_forever() except Exception as e: print(f"Web 服务启动失败: {e}")