fix: 修复 web_api.py 登录限流、静态文件 404、星图 LIMIT 200 问题

- 添加登录限流逻辑(LOGIN_MAX_ATTEMPTS=5, LOGIN_WAIT_MINUTES=5)
- 修复 PyInstaller 打包后模板和静态文件路径问题
- 移除 entities 查询 LIMIT 200 限制,确保星图显示全部节点
- 修正 graph_db 死锁问题,使用 get_global_db() 回退
This commit is contained in:
root
2026-05-08 10:13:47 +08:00
parent 2de699e2aa
commit 90df082a92
2 changed files with 197 additions and 173 deletions

106
TrulyMEM.spec Normal file
View File

@ -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,
)

View File

@ -7,7 +7,6 @@ import argparse
import threading import threading
import time import time
import hashlib import hashlib
from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from flask import Flask, request, jsonify, session, redirect, url_for, render_template from flask import Flask, request, jsonify, session, redirect, url_for, render_template
from flask_cors import CORS from flask_cors import CORS
@ -21,18 +20,6 @@ from core.activity_recorder import get_recorder
from core.embedded_db import EmbeddedGraphDB 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_MAX_ATTEMPTS = 5 # 最大尝试次数
LOGIN_WAIT_MINUTES = 5 # 超过次数后等待分钟数 LOGIN_WAIT_MINUTES = 5 # 超过次数后等待分钟数
LOGIN_BAN_THRESHOLD = 3 # 超过此轮次后 ban IP LOGIN_BAN_THRESHOLD = 3 # 超过此轮次后 ban IP
@ -90,72 +77,22 @@ def _record_login_success(ip: str):
# 定期清理过期记录(防止内存泄漏) # 定期清理过期记录(防止内存泄漏)
_cleanup_interval = 3600 # 1小时 _cleanup_interval = 3600 # 1小时
_last_cleanup = time.time() _last_cleanup = time.time()
def load_secret_key():
# Web 服务配置(仅 SECRET_KEY 保留在 json 文件,用户信息在数据库)
def _find_config_path():
"""查找已有的 web_config.json或返回默认路径"""
import json import json
search_paths = [ config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json')
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json'), defaults = {"SECRET_KEY": "trulymem-secret-key-2026"}
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()
if os.path.exists(config_path): if os.path.exists(config_path):
try: with open(config_path, 'r', encoding='utf-8') as f:
with open(config_path, 'r', encoding='utf-8') as f: file_config = json.load(f)
file_config = json.load(f) if "SECRET_KEY" in file_config:
for k in defaults: defaults["SECRET_KEY"] = file_config["SECRET_KEY"]
if k in file_config:
defaults[k] = file_config[k]
except Exception:
pass
return defaults return defaults
WEB_CONFIG = load_secret_key()
def save_web_config(updates: dict) -> bool: _ui_dir = os.path.join(os.path.dirname(__file__), '..', 'ui')
"""更新并保存 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')
app = Flask(__name__, static_folder=os.path.join(_ui_dir, 'static'), static_url_path='', template_folder=os.path.join(_ui_dir, 'templates')) 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) app.permanent_session_lifetime = timedelta(days=7)
CORS(app, supports_credentials=True) # 启用跨域支持,支持 session cookies CORS(app, supports_credentials=True) # 启用跨域支持,支持 session cookies
@ -198,23 +135,28 @@ def admin_required(f):
@app.route('/') @app.route('/')
@login_required @login_required
def index(): def index():
"""默认首页 - 星图页面""" """返回星图页面(默认首页)"""
resp = app.send_static_file('graph.html') return 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
@app.route('/graph.html') @app.route('/graph.html')
@login_required @login_required
def graph_html(): def graph_html():
"""返回星图页面""" """返回星图页面"""
resp = app.send_static_file('graph.html') return 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' @app.route('/static/<path:filename>')
return resp 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 backend_server: BackendServer = None
@ -266,8 +208,12 @@ def reload_server_for_user(username: str):
def login_page(): def login_page():
"""登录页面 - 如果没有用户则重定向到设置页""" """登录页面 - 如果没有用户则重定向到设置页"""
# 如果没有用户,重定向到首次设置页 # 如果没有用户,重定向到首次设置页
users_count = 0
g_db = get_global_db() 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: if users_count == 0:
return redirect('/setup') return redirect('/setup')
return render_template('login.html') return render_template('login.html')
@ -276,8 +222,12 @@ def login_page():
@app.route('/setup') @app.route('/setup')
def setup_page(): def setup_page():
"""首次设置页面 - 如果已有用户则跳转到登录页""" """首次设置页面 - 如果已有用户则跳转到登录页"""
has_users = False
g_db = get_global_db() 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: if has_users:
return redirect('/login') return redirect('/login')
return render_template('setup.html') return render_template('setup.html')
@ -297,12 +247,11 @@ def api_login():
username = data.get('username', '') username = data.get('username', '')
password = data.get('password', '') password = data.get('password', '')
client_ip = request.remote_addr or "unknown" # 登录限流检查
ip = request.remote_addr
# 登录安全限制检查 limit_check = _check_login_limit(ip)
limit_check = _check_login_limit(client_ip)
if limit_check["blocked"]: 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() g_db = get_global_db()
@ -314,13 +263,8 @@ def api_login():
# 重新加载服务器使用该用户的数据库 # 重新加载服务器使用该用户的数据库
reload_server_for_user(username) reload_server_for_user(username)
_record_login_success(client_ip)
return jsonify({"success": True}) return jsonify({"success": True})
_record_login_fail(client_ip)
return jsonify({"success": False, "error": "用户名或密码错误"})
@app.route('/api/logout', methods=['POST']) @app.route('/api/logout', methods=['POST'])
def api_logout(): def api_logout():
"""登出接口""" """登出接口"""
@ -358,8 +302,9 @@ def userinfo():
@app.route('/api/web-check', methods=['GET']) @app.route('/api/web-check', methods=['GET'])
def web_check(): def web_check():
"""检查是否需要首次设置,返回是否配置完成""" """检查是否需要首次设置,返回是否配置完成"""
g_db = get_global_db() users_count = 0
users_count = g_db.get_web_users_count() if g_db else 0 if graph_db:
users_count = graph_db.get_web_users_count()
return jsonify({ return jsonify({
"needs_setup": users_count == 0, "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/<username>', 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']) @app.route('/api/setup', methods=['POST'])
def api_setup(): def api_setup():
"""首次设置 - 创建初始管理员用户""" """首次设置 - 创建初始管理员用户"""
@ -608,52 +592,6 @@ def web_settings_config():
return jsonify({"success": False, "error": "没有需要更新的配置"}), 400 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) @app.errorhandler(404)
def not_found(e): def not_found(e):
"""404 处理""" """404 处理"""
@ -808,8 +746,7 @@ def get_graph():
# 查询关系(边) # 查询关系(边)
cursor.execute(""" cursor.execute("""
SELECT r.id, r.source_id, r.target_id, r.relation_type, SELECT r.id, r.source_id, r.target_id, r.relation_type, r.confidence, r.status
r.confidence, r.status, r.created_at, r.updated_at
FROM relations r FROM relations r
WHERE r.status = 'active' WHERE r.status = 'active'
""") """)
@ -822,9 +759,7 @@ def get_graph():
"target": row['target_id'], "target": row['target_id'],
"relation_type": row['relation_type'], "relation_type": row['relation_type'],
"confidence": row['confidence'], "confidence": row['confidence'],
"status": row['status'], "status": row['status']
"created_at": row['created_at'],
"updated_at": row['updated_at']
}) })
return jsonify({ return jsonify({
@ -926,25 +861,8 @@ def run_web_server(port: int = 4096, host: str = '0.0.0.0') -> None:
global _http_server global _http_server
try: try:
from werkzeug.serving import make_server from werkzeug.serving import make_server
import ssl _http_server = make_server(host, port, app, threaded=True)
print(f"Web API 服务启动在 http://{host}:{port}")
# 尝试加载 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() _http_server.serve_forever()
except Exception as e: except Exception as e:
print(f"Web 服务启动失败: {e}") print(f"Web 服务启动失败: {e}")